feat: add DSH plugin marketplace and shared MCP
GoodBuddy could share Skills across runtimes, but custom MCP remained limited and DeepSeek Harness could not manage third-party extensions. The app now provides a default-off DSH npm marketplace with managed installation, configuration, failure isolation, and packaged npm support, while assigned custom MCP is available to managed OpenCode, Continue Agent, and DeepSeek Harness in Execute. Third-party DSH install scripts, initialization, and tools run with the current user's permissions. Ask remains read-only at dispatch, and turning off the marketplace hides management without disabling installed plugins. Release note: 新增默认关闭的 DSH 插件市场,并让自定义 MCP 可分配给 OpenCode、Continue 和 DeepSeek Harness;安装第三方插件前会明确提示当前用户权限边界。
This commit is contained in:
@@ -90,6 +90,47 @@ Keep Electron security boundaries intact:
|
|||||||
- Do not show the same event both inline and as an application notification.
|
- Do not show the same event both inline and as an application notification.
|
||||||
Preserve user input and actionable error context when an operation fails.
|
Preserve user input and actionable error context when an operation fails.
|
||||||
|
|
||||||
|
## Commit Messages and Release Notes
|
||||||
|
|
||||||
|
Release notes are derived in part from commit history, so commits for
|
||||||
|
user-visible changes must record product intent rather than only the
|
||||||
|
implementation mechanism.
|
||||||
|
|
||||||
|
- Classify the commit by the user-visible behavior. Use `feat` only for a
|
||||||
|
capability users did not previously have. Use `fix` when restoring intended
|
||||||
|
behavior, removing inconsistency, or making two existing entry points reflect
|
||||||
|
the same underlying setting, even if the implementation adds new
|
||||||
|
synchronization logic.
|
||||||
|
- Keep the subject concise, then add a commit body for non-trivial user-visible
|
||||||
|
changes. State the previous user-facing problem, the resulting behavior, and
|
||||||
|
the affected surface or workflow. Include permissions, migration,
|
||||||
|
compatibility, cost, data, preview-status, or other usage caveats when
|
||||||
|
relevant.
|
||||||
|
- Describe the user outcome precisely. Do not promote an internal refactor,
|
||||||
|
synchronization mechanism, schema change, or newly added implementation code
|
||||||
|
to a product feature unless it creates a genuinely new user capability.
|
||||||
|
- When a change is release-note worthy, include a short `Release note:` line in
|
||||||
|
the commit body written in user-facing language. Prefer a concrete usage
|
||||||
|
scenario and benefit over technical implementation terminology.
|
||||||
|
- Treat commit messages as evidence, not as the sole source of truth. Before
|
||||||
|
drafting release notes, verify the diff and resulting behavior, correct any
|
||||||
|
inaccurate `feat` or `fix` classification, and include actionable usage
|
||||||
|
notes where the change affects defaults, synchronized settings, permissions,
|
||||||
|
resource usage, compatibility, or user data.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
fix: unify project settings across channel entry points
|
||||||
|
|
||||||
|
The top-left project settings and the project settings shown under messaging
|
||||||
|
channels could present or save inconsistent values. They now edit the same
|
||||||
|
project configuration for the project name, description, Runtime, and work
|
||||||
|
mode.
|
||||||
|
|
||||||
|
Release note: 修复左上角项目设置与消息通道项目设置不一致的问题;现在从任一入口修改后,另一处会同步显示相同配置。
|
||||||
|
```
|
||||||
|
|
||||||
## Release Packaging
|
## Release Packaging
|
||||||
|
|
||||||
- `.github/workflows/packages.yml` is the canonical cross-platform packaging
|
- `.github/workflows/packages.yml` is the canonical cross-platform packaging
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ npm run test:watch
|
|||||||
GOODBUDDY_RUN_RUNTIME_E2E=1 npm test -- src/main/agent/runtime-e2e.manual.test.ts
|
GOODBUDDY_RUN_RUNTIME_E2E=1 npm test -- src/main/agent/runtime-e2e.manual.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
该测试可能发起真实外部模型调用。测试不会输出 API Key,文件操作在临时工作区中执行。
|
OpenCode/Continue 用例默认读取 `dist/harness-package-probe/win-unpacked`;也可用
|
||||||
|
`GOODBUDDY_E2E_PACKAGED_ROOT` 指定其他已解包应用目录。该测试可能发起真实外部模型
|
||||||
|
调用。测试不会输出 API Key,文件操作在临时工作区中执行。文件包含经 Main 回环
|
||||||
|
broker 调用已分配自定义 MCP 的真实 OpenCode 和 Continue 用例。
|
||||||
|
|
||||||
## 生产构建
|
## 生产构建
|
||||||
|
|
||||||
@@ -117,10 +120,11 @@ npm run dist:linux:arm64
|
|||||||
|
|
||||||
## Runtime 资源
|
## Runtime 资源
|
||||||
|
|
||||||
发布包会携带经过版本与完整性校验的 OpenCode 和 Continue Runtime:
|
发布包会携带经过版本与完整性校验的 OpenCode、Continue 和 DSH 插件安装 Runtime:
|
||||||
|
|
||||||
- OpenCode 平台二进制来自 `.runtime-resources/<arch>`。
|
- OpenCode 平台二进制来自 `.runtime-resources/<arch>`。
|
||||||
- Continue Runtime 来自锁定版本的 `@continuedev/cli`。
|
- Continue Runtime 来自锁定版本的 `@continuedev/cli`。
|
||||||
|
- DSH 插件安装使用精确锁定并从 `app.asar` 解包的 npm CLI,通过当前 Electron 的 Node 模式运行;最终用户不需要另装 Node.js 或 npm。
|
||||||
- 打包钩子位于 `build/runtime-hooks.cjs`。
|
- 打包钩子位于 `build/runtime-hooks.cjs`。
|
||||||
|
|
||||||
跨架构打包前,确认目标架构的 OpenCode 资源已经准备完成。不要用其他架构的二进制替代目标资源。
|
跨架构打包前,确认目标架构的 OpenCode 资源已经准备完成。不要用其他架构的二进制替代目标资源。
|
||||||
@@ -177,8 +181,10 @@ git push github "$tag"
|
|||||||
4. 本地知识库导入、检索和知识图谱。
|
4. 本地知识库导入、检索和知识图谱。
|
||||||
5. Ask、Execute 的权限边界与旧版 Plan 数据兼容。
|
5. Ask、Execute 的权限边界与旧版 Plan 数据兼容。
|
||||||
6. OpenCode 与 Continue 的权限边界、取消和超时。
|
6. OpenCode 与 Continue 的权限边界、取消和超时。
|
||||||
7. 智能心跳的创建、暂停、恢复和历史记录。
|
7. DeepSeek Harness Ask 拒绝写入和第三方插件工具,Execute 可调用已启用插件工具。
|
||||||
8. 应用退出后无残留 Runtime 子进程。
|
8. DSH 市场可安装、停用、重新启用和移除插件;启动失败插件不会阻止 Host,并显示为自动停用。
|
||||||
|
9. 智能心跳的创建、暂停、恢复和历史记录。
|
||||||
|
10. 应用退出后无残留 Runtime 子进程。
|
||||||
|
|
||||||
DeepSeek Harness 的 Electron Utility Host 可单独执行无模型、无凭据冒烟测试:
|
DeepSeek Harness 的 Electron Utility Host 可单独执行无模型、无凭据冒烟测试:
|
||||||
|
|
||||||
@@ -189,3 +195,38 @@ npm run smoke:deepseek-harness
|
|||||||
该命令先生成 production bundle,再从 CommonJS Electron 主入口启动实际
|
该命令先生成 production bundle,再从 CommonJS Electron 主入口启动实际
|
||||||
`utilityProcess`,等待固定 Host 完成本地主机执行器初始化与内部 ready 握手。它不会发起
|
`utilityProcess`,等待固定 Host 完成本地主机执行器初始化与内部 ready 握手。它不会发起
|
||||||
模型请求,也不会读取或传递 API Key。
|
模型请求,也不会读取或传递 API Key。
|
||||||
|
|
||||||
|
Windows x64 完整打包后的 Utility Host 与内置 npm 冒烟测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
node node_modules/electron-builder/cli.js --win dir --x64 --publish never --config.directories.output=dist/harness-package-probe
|
||||||
|
npm run smoke:deepseek-harness:packaged
|
||||||
|
```
|
||||||
|
|
||||||
|
该测试启动打包后的 Host,并使用打包资源中的准确 npm 版本安装一个本地临时包,
|
||||||
|
确认 npm 依赖闭包、Electron Node 模式、`node` shim 和生命周期脚本都可用。它不访问
|
||||||
|
npm registry,也不运行模型请求。
|
||||||
|
|
||||||
|
真实 DSH 市场测试会访问公共 npm、运行第三方安装与插件代码,因此只在已明确授权时启用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOODBUDDY_DSH_MARKETPLACE_E2E=1 npm test -- src/main/agent/dsh-extension-marketplace.e2e.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
该测试使用临时用户目录,经捆绑 npm 路径安装已审查的最小测试插件,并验证 Host
|
||||||
|
加载和真实工具调用;测试结束后删除临时目录。
|
||||||
|
|
||||||
|
要让真实模型同时验证插件的 Ask 拒绝与 Execute 调用,可显式提供兼容的
|
||||||
|
OpenAI Chat Completions 配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOODBUDDY_DSH_MODEL_E2E=1 \
|
||||||
|
GOODBUDDY_DSH_API_KEY=... \
|
||||||
|
GOODBUDDY_DSH_BASE_URL=https://api.deepseek.com \
|
||||||
|
GOODBUDDY_DSH_MODEL=deepseek-chat \
|
||||||
|
npm test -- src/main/agent/deepseek-harness-acp-e2e.test.ts -t "rejects a real npm plugin"
|
||||||
|
```
|
||||||
|
|
||||||
|
如需覆盖发布包内置 npm 路径,再设置 `GOODBUDDY_DSH_NPM_CLI` 与
|
||||||
|
`GOODBUDDY_DSH_NODE_EXECUTABLE` 指向已解包应用中的 npm CLI 和应用主程序。
|
||||||
|
|||||||
+7
-2
@@ -21,18 +21,23 @@
|
|||||||
|
|
||||||
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。
|
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。
|
||||||
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、超时和活动记录。
|
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、超时和活动记录。
|
||||||
|
- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;Ask 只允许模型调用 `read` 与 `skill`,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。
|
||||||
|
- [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。
|
||||||
- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 运行已启用且受边界约束的工具。
|
- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 运行已启用且受边界约束的工具。
|
||||||
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。
|
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。
|
||||||
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
|
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
|
||||||
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。
|
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。
|
||||||
- [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。
|
- [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。
|
||||||
- [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。
|
- [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。
|
||||||
|
- [ ] **OpenCode Runtime 定制**(规划中):开放当前受控宿主路径实际支持的 Agent 预设、LSP 与 Formatter 状态、Commands 快捷入口和 Runtime 自有上下文压缩;不开放任意插件安装、Session Share、自动 Worktree 或 OpenCode 原生会话持久化。
|
||||||
|
- [ ] **Continue Runtime 定制**(规划中):提供 Rules、Prompt 模板、配置预设、上下文状态与手动压缩,并把 Agent 交互提问转换为 GoodBuddy 统一问答卡片;Hooks、后台 Job 和 Continue 原生会话管理继续暂缓。
|
||||||
- [ ] **可执行 Subagent**(规划中):提供显式 Execute 委派,限制嵌套、并行、Token、时间和工具权限,并保留父子任务审计。
|
- [ ] **可执行 Subagent**(规划中):提供显式 Execute 委派,限制嵌套、并行、Token、时间和工具权限,并保留父子任务审计。
|
||||||
|
|
||||||
### Skills、MCP 与知识库
|
### Skills、MCP 与知识库
|
||||||
|
|
||||||
- [x] **Skills 按需接入**:使用有界资源和受控 Runtime 边界。
|
- [x] **Skills 按需接入**:可分配给直连模型、OpenCode、Continue 和 DeepSeek Harness,并使用有界资源和受控 Runtime 边界。
|
||||||
- [x] **MCP Tools**:直连模型可使用显式启用的 MCP Tools,并可在模型轮次间按需刷新动态 MCP 工具。
|
- [x] **MCP Tools**:显式启用的自定义 MCP 可按 Runtime 分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent Execute 和 DeepSeek Harness,并仅在 Execute 加载;Agent 子进程只获得按请求签发的本机回环权限,MCP 地址、命令和凭据保留在 Main,动态工具仍会重新发现并经过现有执行记录与权限边界。
|
||||||
|
- [ ] **MCP Prompts 与 Resources**(规划中):在现有 MCP Tools 之外发现有界的 Prompt 和 Resource 元数据,将 Prompt 接入可编辑的聊天快捷模板,并按 Runtime 实际支持情况逐步共享。
|
||||||
- [x] **本地知识库**:支持文件、目录和网页导入、SQLite FTS5 检索及来源追溯。
|
- [x] **本地知识库**:支持文件、目录和网页导入、SQLite FTS5 检索及来源追溯。
|
||||||
- [x] **知识图谱**:支持规则、模型和混合抽取,以及实体、关系、别名和证据维护。
|
- [x] **知识图谱**:支持规则、模型和混合抽取,以及实体、关系、别名和证据维护。
|
||||||
- [x] **向量模型配置与检索**:可配置兼容 Embeddings 接口并用于语义检索。
|
- [x] **向量模型配置与检索**:可配置兼容 Embeddings 接口并用于语义检索。
|
||||||
|
|||||||
+3
-2
@@ -10,8 +10,8 @@ A secure, cross-platform, local-first desktop AI assistant and Agent workspace.
|
|||||||
|
|
||||||
- **Controlled execution**: `Ask` stays read-only; `Execute` runs only enabled tools within defined boundaries and records their activity.
|
- **Controlled execution**: `Ask` stays read-only; `Execute` runs only enabled tools within defined boundaries and records their activity.
|
||||||
- **Local-first data**: Conversations, tasks, artifacts, memory, knowledge bases, and graphs are stored in local SQLite. API keys are encrypted by the operating system.
|
- **Local-first data**: Conversations, tasks, artifacts, memory, knowledge bases, and graphs are stored in local SQLite. API keys are encrypted by the operating system.
|
||||||
- **Multiple runtimes**: Connect directly to models or use OpenCode and Continue, with cancellation, timeouts, output limits, and process cleanup.
|
- **Multiple runtimes**: Connect directly to models or use OpenCode, Continue, and the preview DeepSeek Harness, with cancellation, timeouts, output limits, and process cleanup.
|
||||||
- **Open integrations**: Supports OpenAI Responses, OpenAI-compatible Chat Completions, Anthropic Messages, OpenAI Images, Embeddings, Skills, and MCP.
|
- **Open integrations**: Supports OpenAI Responses, OpenAI-compatible Chat Completions, Anthropic Messages, OpenAI Images, Embeddings, cross-runtime Skills and custom MCP, plus a default-off DeepSeek Harness npm plugin marketplace that users enable explicitly.
|
||||||
- **Knowledge workspace**: Import files, folders, and web pages, then search them with full-text, phrase, vector, and graph retrieval.
|
- **Knowledge workspace**: Import files, folders, and web pages, then search them with full-text, phrase, vector, and graph retrieval.
|
||||||
- **Work management**: Organize projects, conversations, tasks, activity, artifacts, memory, Magic Notes, and Smart Heartbeat.
|
- **Work management**: Organize projects, conversations, tasks, activity, artifacts, memory, Magic Notes, and Smart Heartbeat.
|
||||||
- **Remote channels**: Connect WeChat ClawBot, WeCom, and DingTalk with separate remote sessions for each sender.
|
- **Remote channels**: Connect WeChat ClawBot, WeCom, and DingTalk with separate remote sessions for each sender.
|
||||||
@@ -59,6 +59,7 @@ See [BUILD.md](BUILD.md) for build and packaging instructions.
|
|||||||
- Model requests are sent only to services selected by the user.
|
- Model requests are sent only to services selected by the user.
|
||||||
- Local data stays in the operating system's application data directory by default.
|
- Local data stays in the operating system's application data directory by default.
|
||||||
- The Renderer has no access to raw Electron APIs or model credentials.
|
- The Renderer has no access to raw Electron APIs or model credentials.
|
||||||
|
- The DeepSeek Harness plugin marketplace is off by default. After it is enabled and a third-party plugin is installed, its install scripts, initialization, and Execute tools run with the current user's permissions. Turning off the marketplace only hides its catalog and management interface; it does not disable or uninstall existing plugins. Installation requires explicit confirmation, and Ask limits only model tool calls.
|
||||||
- Remote delegation is disabled until the user configures an endpoint and token.
|
- Remote delegation is disabled until the user configures an endpoint and token.
|
||||||
- Private-network compatibility permits in-app HTTP and non-standard HTTPS certificates. WeChat credential and media endpoints remain strictly validated.
|
- Private-network compatibility permits in-app HTTP and non-standard HTTPS certificates. WeChat credential and media endpoints remain strictly validated.
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
|
|
||||||
- **安全执行**:`Ask` 保持只读;`Execute` 仅运行已启用且受边界约束的工具,并保留活动记录。
|
- **安全执行**:`Ask` 保持只读;`Execute` 仅运行已启用且受边界约束的工具,并保留活动记录。
|
||||||
- **本地优先**:会话、任务、成果、记忆、知识库和图谱保存在本地 SQLite;API Key 由系统安全存储加密。
|
- **本地优先**:会话、任务、成果、记忆、知识库和图谱保存在本地 SQLite;API Key 由系统安全存储加密。
|
||||||
- **多 Runtime**:支持直连模型、OpenCode 和 Continue,统一处理取消、超时、输出限制和进程退出。
|
- **多 Runtime**:支持直连模型、OpenCode、Continue 和预览版 DeepSeek Harness,统一处理取消、超时、输出限制和进程退出。
|
||||||
- **开放连接**:支持 OpenAI Responses、OpenAI 兼容 Chat Completions、Anthropic Messages、OpenAI Images、Embeddings、Skills 和 MCP。
|
- **开放连接**:支持 OpenAI Responses、OpenAI 兼容 Chat Completions、Anthropic Messages、OpenAI Images、Embeddings、跨 Runtime Skills 与自定义 MCP,以及默认关闭、由用户显式开启的 DeepSeek Harness npm 插件市场。
|
||||||
- **知识工作区**:支持文件、目录和网页导入,以及全文、词组、向量和图谱混合检索。
|
- **知识工作区**:支持文件、目录和网页导入,以及全文、词组、向量和图谱混合检索。
|
||||||
- **工作管理**:集中管理 Projects、对话、任务、活动、成果、记忆、魔法笔记和智能心跳。
|
- **工作管理**:集中管理 Projects、对话、任务、活动、成果、记忆、魔法笔记和智能心跳。
|
||||||
- **远程通道**:支持微信 ClawBot、企业微信和钉钉,每个发送者使用独立远程会话。
|
- **远程通道**:支持微信 ClawBot、企业微信和钉钉,每个发送者使用独立远程会话。
|
||||||
@@ -59,6 +59,7 @@ npm run dev
|
|||||||
- 模型请求只发送到用户选择的服务。
|
- 模型请求只发送到用户选择的服务。
|
||||||
- 本地数据默认保存在系统应用数据目录。
|
- 本地数据默认保存在系统应用数据目录。
|
||||||
- Renderer 不接触原始 Electron API 或模型凭据。
|
- Renderer 不接触原始 Electron API 或模型凭据。
|
||||||
|
- DeepSeek Harness 插件市场默认关闭;开启并安装第三方插件后,其安装脚本、初始化和 Execute 工具以当前用户权限运行。关闭市场只隐藏目录和管理界面,不会停用或卸载已有插件;安装前会明确确认,Ask 只限制模型工具调用。
|
||||||
- 远程委派仅在用户配置端点和令牌后启用。
|
- 远程委派仅在用户配置端点和令牌后启用。
|
||||||
- 内网兼容模式允许应用内 HTTP 和非标准 HTTPS 证书;微信凭据和媒体端点仍执行严格校验。
|
- 内网兼容模式允许应用内 HTTP 和非标准 HTTPS 证书;微信凭据和媒体端点仍执行严格校验。
|
||||||
|
|
||||||
|
|||||||
@@ -472,6 +472,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
|||||||
- 对话标题和当前项目范围位于 `PageHeader` 或对话上下文区,不在消息流中重复。
|
- 对话标题和当前项目范围位于 `PageHeader` 或对话上下文区,不在消息流中重复。
|
||||||
- 模式、模型或工具权限属于上下文控制,不与页面导航页签混用。
|
- 模式、模型或工具权限属于上下文控制,不与页面导航页签混用。
|
||||||
- 模型、专家角色和工作模式使用统一的上下文单选菜单,并保持菜单互斥、键盘可达和选中状态明确。
|
- 模型、专家角色和工作模式使用统一的上下文单选菜单,并保持菜单互斥、键盘可达和选中状态明确。
|
||||||
|
- Agent 回复进行中锁定模型、专家角色和工作模式选择器,并关闭已打开的上下文菜单;回复结束或停止后再恢复选择,避免界面状态与本次运行实际使用的上下文不一致。
|
||||||
- 已选择的工作模式在触发按钮中只显示 `Ask` 或 `Execute`;完整中文含义和说明保留在菜单选项、可访问名称及输入区下方的模式说明中。
|
- 已选择的工作模式在触发按钮中只显示 `Ask` 或 `Execute`;完整中文含义和说明保留在菜单选项、可访问名称及输入区下方的模式说明中。
|
||||||
- 宽度大于 `700px` 时,添加内容、知识范围、专家、模式和模型控件保持同一行;仅在窄输入区中换行,不能因为允许换行而让所有窗口都固定显示两行。
|
- 宽度大于 `700px` 时,添加内容、知识范围、专家、模式和模型控件保持同一行;仅在窄输入区中换行,不能因为允许换行而让所有窗口都固定显示两行。
|
||||||
- 输入框原生支持 `Ctrl+V`:文本直接进入草稿,图片转换为本次消息附件。文件选择由上传按钮承担,不再提供独立“读取剪贴板”按钮;默认工具栏也不提供“截取当前屏幕”和“选择应用窗口”入口,避免与系统粘贴、文件选择和后续工具执行重复。
|
- 输入框原生支持 `Ctrl+V`:文本直接进入草稿,图片转换为本次消息附件。文件选择由上传按钮承担,不再提供独立“读取剪贴板”按钮;默认工具栏也不提供“截取当前屏幕”和“选择应用窗口”入口,避免与系统粘贴、文件选择和后续工具执行重复。
|
||||||
|
|||||||
+67
-1
@@ -69,7 +69,10 @@ const portableRequiredFiles = [
|
|||||||
'resources/icon.ico',
|
'resources/icon.ico',
|
||||||
'resources/tray-icon.png',
|
'resources/tray-icon.png',
|
||||||
'resources/runtimes/opencode/opencode.exe',
|
'resources/runtimes/opencode/opencode.exe',
|
||||||
'resources/runtimes/continue/package.json'
|
'resources/runtimes/continue/package.json',
|
||||||
|
'resources/runtimes/npm/bin/npm-cli.js',
|
||||||
|
'resources/runtimes/npm/package.json',
|
||||||
|
'resources/runtimes/npm/node_modules/graceful-fs/package.json'
|
||||||
]
|
]
|
||||||
const maxPortableZipEntries = 50_000
|
const maxPortableZipEntries = 50_000
|
||||||
const maxPortableCentralDirectoryBytes = 64 * 1024 * 1024
|
const maxPortableCentralDirectoryBytes = 64 * 1024 * 1024
|
||||||
@@ -441,6 +444,20 @@ function assertFile(filePath, description) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readJsonFile(filePath, description) {
|
||||||
|
assertFile(filePath, description)
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(filePath, 'utf8'))
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`${description}无效:${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
{ cause: error }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAsarEntry(filePath) {
|
function normalizeAsarEntry(filePath) {
|
||||||
return filePath.split('/').join(sep)
|
return filePath.split('/').join(sep)
|
||||||
}
|
}
|
||||||
@@ -795,6 +812,45 @@ function verifyHarnessPackage(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const npmRoot = join(resources, 'runtimes', 'npm')
|
||||||
|
const npmManifest = readJsonFile(
|
||||||
|
join(npmRoot, 'package.json'),
|
||||||
|
'DSH 插件安装 npm 元数据'
|
||||||
|
)
|
||||||
|
if (npmManifest.version !== packageJson.dependencies?.npm) {
|
||||||
|
throw new Error(
|
||||||
|
`DSH 插件安装 npm 版本错误:期望 ${String(packageJson.dependencies?.npm)},实际 ${String(npmManifest.version)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertFile(
|
||||||
|
join(npmRoot, 'bin', 'npm-cli.js'),
|
||||||
|
'DSH 插件安装 npm CLI'
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
!Array.isArray(npmManifest.bundleDependencies) ||
|
||||||
|
npmManifest.bundleDependencies.length === 0
|
||||||
|
) {
|
||||||
|
throw new Error('DSH 插件安装 npm 依赖清单无效')
|
||||||
|
}
|
||||||
|
for (const packageName of npmManifest.bundleDependencies) {
|
||||||
|
if (
|
||||||
|
typeof packageName !== 'string' ||
|
||||||
|
!/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u.test(
|
||||||
|
packageName
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error('DSH 插件安装 npm 依赖清单无效')
|
||||||
|
}
|
||||||
|
assertFile(
|
||||||
|
join(
|
||||||
|
npmRoot,
|
||||||
|
'node_modules',
|
||||||
|
...packageName.split('/'),
|
||||||
|
'package.json'
|
||||||
|
),
|
||||||
|
`DSH 插件安装 npm 依赖 ${packageName}`
|
||||||
|
)
|
||||||
|
}
|
||||||
const targetKoffiManifest = readJson(
|
const targetKoffiManifest = readJson(
|
||||||
`node_modules/${target.koffiPackage}/package.json`,
|
`node_modules/${target.koffiPackage}/package.json`,
|
||||||
`${target.koffiPackage} 元数据`
|
`${target.koffiPackage} 元数据`
|
||||||
@@ -903,6 +959,16 @@ function verifyUnpackedOutput(directory, options) {
|
|||||||
join(resources, 'runtimes', 'continue', 'dist', 'index.js'),
|
join(resources, 'runtimes', 'continue', 'dist', 'index.js'),
|
||||||
'Continue Runtime'
|
'Continue Runtime'
|
||||||
)
|
)
|
||||||
|
assertFile(
|
||||||
|
join(
|
||||||
|
resources,
|
||||||
|
'runtimes',
|
||||||
|
'npm',
|
||||||
|
'bin',
|
||||||
|
'npm-cli.js'
|
||||||
|
),
|
||||||
|
'DSH 插件安装 npm Runtime'
|
||||||
|
)
|
||||||
verifyHarnessPackage(resources, options)
|
verifyHarnessPackage(resources, options)
|
||||||
for (const [filePath, label] of [
|
for (const [filePath, label] of [
|
||||||
[applicationExecutable, '应用主程序'],
|
[applicationExecutable, '应用主程序'],
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ async function run() {
|
|||||||
harnessVersion: '0.1.0-rc.6',
|
harnessVersion: '0.1.0-rc.6',
|
||||||
credentialRefs: ['GOODBUDDY_HARNESS_MODEL_API_KEY'],
|
credentialRefs: ['GOODBUDDY_HARNESS_MODEL_API_KEY'],
|
||||||
skillPackages: [],
|
skillPackages: [],
|
||||||
|
extensionPackages: [],
|
||||||
maxFrameBytes: 1024 * 1024
|
maxFrameBytes: 1024 * 1024
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
const { spawn } = require('node:child_process')
|
const { spawn } = require('node:child_process')
|
||||||
const {
|
const {
|
||||||
|
chmod,
|
||||||
copyFile,
|
copyFile,
|
||||||
mkdir,
|
mkdir,
|
||||||
mkdtemp,
|
mkdtemp,
|
||||||
@@ -11,7 +12,7 @@ const {
|
|||||||
} = require('node:fs/promises')
|
} = require('node:fs/promises')
|
||||||
const { statSync } = require('node:fs')
|
const { statSync } = require('node:fs')
|
||||||
const { tmpdir } = require('node:os')
|
const { tmpdir } = require('node:os')
|
||||||
const { join, resolve } = require('node:path')
|
const { delimiter, join, resolve } = require('node:path')
|
||||||
|
|
||||||
const unpackedPath = process.argv[2]
|
const unpackedPath = process.argv[2]
|
||||||
? resolve(process.argv[2])
|
? resolve(process.argv[2])
|
||||||
@@ -28,20 +29,57 @@ const host = join(
|
|||||||
'main',
|
'main',
|
||||||
'deepseek-harness-host-bootstrap.js'
|
'deepseek-harness-host-bootstrap.js'
|
||||||
)
|
)
|
||||||
|
const npmRoot = join(unpackedPath, 'resources', 'runtimes', 'npm')
|
||||||
|
const npmCli = join(npmRoot, 'bin', 'npm-cli.js')
|
||||||
|
const npmManifestPath = join(npmRoot, 'package.json')
|
||||||
|
|
||||||
for (const [path, description] of [
|
for (const [path, description] of [
|
||||||
[executable, 'packaged Electron executable'],
|
[executable, 'packaged Electron executable'],
|
||||||
[host, 'packaged DeepSeek Harness host']
|
[host, 'packaged DeepSeek Harness host'],
|
||||||
|
[npmCli, 'packaged npm CLI'],
|
||||||
|
[npmManifestPath, 'packaged npm manifest']
|
||||||
]) {
|
]) {
|
||||||
if (!statSync(path, { throwIfNoEntry: false })?.isFile()) {
|
if (!statSync(path, { throwIfNoEntry: false })?.isFile()) {
|
||||||
throw new Error(`${description} is missing: ${path}`)
|
throw new Error(`${description} is missing: ${path}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function run(command, args, env) {
|
function quotePosixShell(value) {
|
||||||
|
return `'${value.replaceAll("'", "'\\''")}'`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareNodeCommand(directory) {
|
||||||
|
await mkdir(directory, { recursive: true })
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
await writeFile(
|
||||||
|
join(directory, 'node.cmd'),
|
||||||
|
[
|
||||||
|
'@echo off',
|
||||||
|
'set "ELECTRON_RUN_AS_NODE=1"',
|
||||||
|
`"${executable.replaceAll('%', '%%')}" %*`,
|
||||||
|
''
|
||||||
|
].join('\r\n'),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const commandPath = join(directory, 'node')
|
||||||
|
await writeFile(
|
||||||
|
commandPath,
|
||||||
|
[
|
||||||
|
'#!/bin/sh',
|
||||||
|
`ELECTRON_RUN_AS_NODE=1 exec ${quotePosixShell(executable)} "$@"`,
|
||||||
|
''
|
||||||
|
].join('\n'),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
await chmod(commandPath, 0o700)
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args, env, cwd = resolve('.')) {
|
||||||
return new Promise((resolveExit, rejectExit) => {
|
return new Promise((resolveExit, rejectExit) => {
|
||||||
const child = spawn(command, args, {
|
const child = spawn(command, args, {
|
||||||
cwd: resolve('.'),
|
cwd,
|
||||||
env,
|
env,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
windowsHide: true
|
windowsHide: true
|
||||||
@@ -67,6 +105,9 @@ async function main() {
|
|||||||
const project = join(root, 'app')
|
const project = join(root, 'app')
|
||||||
const profile = join(root, 'profile')
|
const profile = join(root, 'profile')
|
||||||
const resultPath = join(root, 'result.json')
|
const resultPath = join(root, 'result.json')
|
||||||
|
const packageManagerBin = join(root, 'package-manager-bin')
|
||||||
|
const npmProject = join(root, 'npm-project')
|
||||||
|
const npmFixture = join(root, 'npm-fixture')
|
||||||
await mkdir(project, { recursive: true })
|
await mkdir(project, { recursive: true })
|
||||||
|
|
||||||
await copyFile(
|
await copyFile(
|
||||||
@@ -116,9 +157,16 @@ async function main() {
|
|||||||
'never',
|
'never',
|
||||||
`--config.directories.output=${join(root, 'dist')}`
|
`--config.directories.output=${join(root, 'dist')}`
|
||||||
]
|
]
|
||||||
if (process.env.GOODBUDDY_ELECTRON_DIST) {
|
const electronDist = process.env.GOODBUDDY_ELECTRON_DIST
|
||||||
|
? resolve(process.env.GOODBUDDY_ELECTRON_DIST)
|
||||||
|
: resolve('node_modules/electron/dist')
|
||||||
|
if (
|
||||||
|
statSync(electronDist, {
|
||||||
|
throwIfNoEntry: false
|
||||||
|
})?.isDirectory()
|
||||||
|
) {
|
||||||
packageArguments.push(
|
packageArguments.push(
|
||||||
`--config.electronDist=${resolve(process.env.GOODBUDDY_ELECTRON_DIST)}`
|
`--config.electronDist=${electronDist}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const packaged = await run(
|
const packaged = await run(
|
||||||
@@ -153,7 +201,102 @@ async function main() {
|
|||||||
`Packaged DeepSeek Harness smoke failed (${executed.exitCode}, ${executed.signal ?? 'no signal'}): ${JSON.stringify(result)} ${executed.output.trim()}`
|
`Packaged DeepSeek Harness smoke failed (${executed.exitCode}, ${executed.signal ?? 'no signal'}): ${JSON.stringify(result)} ${executed.output.trim()}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const npmManifest = JSON.parse(
|
||||||
|
await readFile(npmManifestPath, 'utf8')
|
||||||
|
)
|
||||||
|
await prepareNodeCommand(packageManagerBin)
|
||||||
|
await mkdir(npmProject, { recursive: true })
|
||||||
|
await mkdir(npmFixture, { recursive: true })
|
||||||
|
await writeFile(
|
||||||
|
join(npmProject, 'package.json'),
|
||||||
|
'{"name":"goodbuddy-packaged-npm-project","version":"1.0.0","private":true}\n',
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
await writeFile(
|
||||||
|
join(npmFixture, 'package.json'),
|
||||||
|
`${JSON.stringify({
|
||||||
|
name: 'goodbuddy-packaged-npm-smoke',
|
||||||
|
version: '1.0.0',
|
||||||
|
scripts: {
|
||||||
|
install: 'node install.cjs'
|
||||||
|
}
|
||||||
|
})}\n`,
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
await writeFile(
|
||||||
|
join(npmFixture, 'install.cjs'),
|
||||||
|
"require('node:fs').writeFileSync(require('node:path').join(__dirname, 'lifecycle-ran.txt'), 'ready\\n')\n",
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const inheritedPath =
|
||||||
|
process.env.PATH ?? process.env.Path ?? ''
|
||||||
|
const npmEnvironment = {
|
||||||
|
...process.env,
|
||||||
|
PATH: inheritedPath
|
||||||
|
? `${packageManagerBin}${delimiter}${inheritedPath}`
|
||||||
|
: packageManagerBin,
|
||||||
|
Path: inheritedPath
|
||||||
|
? `${packageManagerBin}${delimiter}${inheritedPath}`
|
||||||
|
: packageManagerBin,
|
||||||
|
ELECTRON_RUN_AS_NODE: '1',
|
||||||
|
npm_execpath: npmCli,
|
||||||
|
npm_node_execpath: executable,
|
||||||
|
npm_config_audit: 'false',
|
||||||
|
npm_config_fund: 'false',
|
||||||
|
npm_config_update_notifier: 'false'
|
||||||
|
}
|
||||||
|
const npmVersion = await run(
|
||||||
|
executable,
|
||||||
|
[npmCli, '--version'],
|
||||||
|
npmEnvironment,
|
||||||
|
npmProject
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
npmVersion.exitCode !== 0 ||
|
||||||
|
npmVersion.signal ||
|
||||||
|
npmVersion.output.trim() !== npmManifest.version
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Packaged npm version smoke failed: ${npmVersion.output.trim()}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const installed = await run(
|
||||||
|
executable,
|
||||||
|
[
|
||||||
|
npmCli,
|
||||||
|
'install',
|
||||||
|
'--save-exact',
|
||||||
|
'--no-audit',
|
||||||
|
'--no-fund',
|
||||||
|
'--dangerously-allow-all-scripts',
|
||||||
|
'--loglevel=error',
|
||||||
|
npmFixture
|
||||||
|
],
|
||||||
|
npmEnvironment,
|
||||||
|
npmProject
|
||||||
|
)
|
||||||
|
if (installed.exitCode !== 0 || installed.signal) {
|
||||||
|
throw new Error(
|
||||||
|
`Packaged npm install smoke failed: ${installed.output.trim()}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const lifecycleMarker = await readFile(
|
||||||
|
join(
|
||||||
|
npmProject,
|
||||||
|
'node_modules',
|
||||||
|
'goodbuddy-packaged-npm-smoke',
|
||||||
|
'lifecycle-ran.txt'
|
||||||
|
),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
if (lifecycleMarker !== 'ready\n') {
|
||||||
|
throw new Error('Packaged npm lifecycle smoke failed')
|
||||||
|
}
|
||||||
console.log('Packaged DeepSeek Harness utility smoke: ready')
|
console.log('Packaged DeepSeek Harness utility smoke: ready')
|
||||||
|
console.log(
|
||||||
|
`Packaged npm install smoke: ready (${npmManifest.version})`
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
await rm(root, { recursive: true, force: true })
|
await rm(root, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,27 +13,27 @@
|
|||||||
| GoodBuddy 目标平台 | Windows、macOS、Linux,x64 与 arm64 |
|
| GoodBuddy 目标平台 | Windows、macOS、Linux,x64 与 arm64 |
|
||||||
| 本文性质 | 设计与发布验收约定 |
|
| 本文性质 | 设计与发布验收约定 |
|
||||||
|
|
||||||
本文定义 DeepSeek Harness 在 GoodBuddy 中的架构边界、协议、执行策略、界面、打包和验收要求。实现必须继续遵守 GoodBuddy 已有的 Main 进程安全边界、Ask/Execute 语义、授权、取消、超时、有界输出和资源回收约定。
|
本文定义 DeepSeek Harness 在 GoodBuddy 中的架构边界、协议、执行策略、插件市场、界面、打包和验收要求。实现必须继续遵守 GoodBuddy 已有的 Main 进程安全边界、Ask/Execute 语义、取消、超时、有界输出和资源回收约定。
|
||||||
|
|
||||||
## 2. 摘要
|
## 2. 摘要
|
||||||
|
|
||||||
DeepSeek Harness 的底层库使用 Cordis 组合服务。GoodBuddy 不采用官方产品 profile、插件安装或市场机制,也不让用户配置覆盖安全服务,而是增加一个实验性的第三 Runtime,并完全自行维护 Host、控制协议、生命周期和兼容层。上游 DSH 包只是精确锁定并逐次审查的实现依赖,不构成 GoodBuddy 对 DSH 插件 ABI、插件目录或产品路线的承诺。
|
DeepSeek Harness 的底层库使用 Cordis 组合服务。GoodBuddy 不采用官方产品 profile,也不允许用户配置覆盖内部 Host 或控制服务;GoodBuddy 自行维护 Host、控制协议和生命周期,同时提供一个由 Main 管理、默认关闭的 npm 插件市场。用户显式开启后,市场只搜索带精确 `dsh-plugin` 关键字的公共 npm 包,不代表 GoodBuddy 审核、推荐或承诺兼容这些包。
|
||||||
|
|
||||||
GoodBuddy 并不迫切于把该能力做成 DSH 插件或进入插件市场。当前优先级是向用户提供稳定、可靠、可审计且可完整回收的 Runtime;只有未来真实用户需求和成熟度证明插件化确有价值时,才重新评估该形态。
|
用户明确安装并启用的插件以当前用户权限运行。Ask/Execute 只控制模型经过 `tools/execute` 发起的工具调用:Ask 只允许 `read` 与 `skill`,Execute 放行 Host 中全部已注册工具。插件安装脚本和初始化代码不属于模型工具调用,不能由 Ask 限制,因此界面在安装前必须明确确认这一边界。
|
||||||
|
|
||||||
整体分成两个互相约束的部分:
|
整体分成两个互相约束的部分:
|
||||||
|
|
||||||
1. **GoodBuddy Main Control Plane**
|
1. **GoodBuddy Main Control Plane**
|
||||||
- 运行在 Electron Main 进程。
|
- 运行在 Electron Main 进程。
|
||||||
- 持有加密设置、模型连接选择、Ask 只读策略、Runtime 生命周期和审计归属。
|
- 持有加密设置、模型连接选择、Ask 只读策略、Runtime 生命周期、插件市场状态和审计归属。
|
||||||
- 通过 Electron `utilityProcess` 启动受控 Harness 子进程。
|
- 通过 Electron `utilityProcess` 启动受控 Harness 子进程。
|
||||||
- 对环境、输入、输出、超时、取消和进程树执行强制限制。
|
- 对环境、输入、输出、超时、取消和进程树执行强制限制,并只把已启用插件的受管入口传给 Host。
|
||||||
|
|
||||||
2. **GoodBuddy Harness Control Plane**
|
2. **GoodBuddy Harness Control Plane**
|
||||||
- 运行在 Harness 子进程内,是 Host 私有的内部控制组件,不导出 Cordis 插件入口。
|
- 运行在 Harness 子进程内,是 Host 私有的内部控制组件,不导出 Cordis 插件入口。
|
||||||
- 使用 ACP 兼容的 JSON-RPC stdio 作为基础控制面。
|
- 使用 ACP 兼容的 JSON-RPC stdio 作为基础控制面。
|
||||||
- 增加 GoodBuddy 所需的能力握手、每轮权限准备、会话释放、工具事件、推理、用量和安全凭据请求扩展。
|
- 增加 GoodBuddy 所需的能力握手、每轮权限准备、会话释放、工具事件、推理、用量和安全凭据请求扩展。
|
||||||
- 与 GoodBuddy Host 一起维护、构建和发布,不设计为独立 npm 包、`dsh.bundle` 或市场插件。
|
- 与 GoodBuddy Host 一起维护、构建和发布,不设计为独立 npm 包或市场插件;第三方插件只作为显式配置加载。
|
||||||
|
|
||||||
DeepSeek Harness 不替换 OpenCode、Continue 或直连模型 Runtime。用户可以按全局、项目、会话或消息通道继续选择现有 Runtime。
|
DeepSeek Harness 不替换 OpenCode、Continue 或直连模型 Runtime。用户可以按全局、项目、会话或消息通道继续选择现有 Runtime。
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ Filesystem Provider,以 GoodBuddy 客户端进程的当前用户权限运行
|
|||||||
|
|
||||||
### 3.3 自维护边界
|
### 3.3 自维护边界
|
||||||
|
|
||||||
GoodBuddy 不急于把该 Runtime 包装成标准 DSH 插件,也不以进入官方或第三方插件市场为近期目标。所有入口都随 GoodBuddy 发布,只有 GoodBuddy Main 可以启动并使用内部 Host。是否采用上游新版本或未来重新评估插件形态,只由真实用户价值、安全审查和六平台稳定性决定,不跟随市场机制或上游发布节奏。
|
GoodBuddy 自己的 Runtime 和控制面不包装成标准 DSH 插件,也不加载用户 profile 或自定义 Host。只有 GoodBuddy Main 可以启动内部 Host、选择受管插件入口并处理启动失败。公共 npm 市场是第三方扩展来源,不改变 GoodBuddy 对内部 Host、Ask/Execute 语义和协议版本的控制。
|
||||||
|
|
||||||
## 4. 目标与非目标
|
## 4. 目标与非目标
|
||||||
|
|
||||||
@@ -77,6 +77,9 @@ GoodBuddy 不急于把该 Runtime 包装成标准 DSH 插件,也不以进入
|
|||||||
- 使用 GoodBuddy 管理的模型连接,不在 Renderer 或持久化 Harness 配置中写入 API Key。
|
- 使用 GoodBuddy 管理的模型连接,不在 Renderer 或持久化 Harness 配置中写入 API Key。
|
||||||
- Ask 模式在 Runtime 工具分发边界强制只读,阻止 Shell、写入和编辑工具。
|
- Ask 模式在 Runtime 工具分发边界强制只读,阻止 Shell、写入和编辑工具。
|
||||||
- Execute 模式使用 DSH 本地 Provider,以当前用户权限执行文件与命令工具;工作区是默认工作目录,不是 OS 权限边界。
|
- Execute 模式使用 DSH 本地 Provider,以当前用户权限执行文件与命令工具;工作区是默认工作目录,不是 OS 权限边界。
|
||||||
|
- 提供默认关闭的公共 npm DSH 插件市场总开关;用户显式开启后可搜索、查看详情、精确版本安装、启用、停用、配置和移除,首次安装前明确确认当前用户权限。
|
||||||
|
- 只加载 Main 明确传入的已启用插件;单个插件启动失败不得阻止 Host,并自动停用失败插件。
|
||||||
|
- 允许 Skills 和自定义 MCP 显式分配给 DeepSeek Harness;自定义 MCP 只在 Execute 中通过 Main 代理。
|
||||||
- 支持多会话、同会话串行、跨会话并行。
|
- 支持多会话、同会话串行、跨会话并行。
|
||||||
- 支持按请求取消、超时、会话释放和应用退出时完整回收。
|
- 支持按请求取消、超时、会话释放和应用退出时完整回收。
|
||||||
- 输出文本、推理、工具参数、工具结果、stderr 和协议队列全部有界。
|
- 输出文本、推理、工具参数、工具结果、stderr 和协议队列全部有界。
|
||||||
@@ -87,13 +90,14 @@ GoodBuddy 不急于把该 Runtime 包装成标准 DSH 插件,也不以进入
|
|||||||
|
|
||||||
- 不替换 OpenCode、Continue 或直连模型 Runtime。
|
- 不替换 OpenCode、Continue 或直连模型 Runtime。
|
||||||
- 不开放用户 Cordis profile、cordis.patch.yml 或 $DSH_HOME 全局补丁覆盖。
|
- 不开放用户 Cordis profile、cordis.patch.yml 或 $DSH_HOME 全局补丁覆盖。
|
||||||
- 不提供外部 Host、自定义 Harness Control Plane、DSH 插件安装或市场入口。
|
- 不提供外部 Host、自定义 Harness Control Plane、任意本地模块路径或用户 profile 插件目录。
|
||||||
- 不加载 Harness Web UI、HMR、遥测、自动更新或目录选择器。
|
- 不加载 Harness Web UI、HMR、遥测、自动更新或目录选择器。
|
||||||
- 不提供 Runtime OS 沙箱模式或相关持久设置。
|
- 不提供 Runtime OS 沙箱模式或相关持久设置。
|
||||||
- 不向 Utility 暴露 MCP 凭据或建立直连 MCP Client。只有用户明确分配给 Harness 的 MCP 工具可以通过 Main 代理调用。
|
- 不向 Utility 暴露 MCP 凭据或建立直连 MCP Client。只有用户明确分配给 Harness 的 MCP 工具可以通过 Main 代理调用。
|
||||||
- 不在首版向 Harness 暴露 GoodBuddy 浏览器控制、知识库或 Magic Notes。
|
- 不在首版向 Harness 暴露 GoodBuddy 浏览器控制、知识库或 Magic Notes。
|
||||||
- 不在首版支持图像输入、会话恢复、Harness Subagent、后台 Job、Hook、Web Search 或 Workflow。
|
- 不在首版支持图像输入、会话恢复、Harness Subagent、后台 Job、Hook、Web Search 或 Workflow。
|
||||||
- 不发布独立 npm 包,也不创建上游 PR。
|
- 不发布独立 npm 包,也不创建上游 PR。
|
||||||
|
- 不为第三方插件增加权限矩阵、风险等级、逐工具审批、沙箱档位、回滚代际或兼容性背书。
|
||||||
|
|
||||||
## 5. 核心设计决策
|
## 5. 核心设计决策
|
||||||
|
|
||||||
@@ -110,9 +114,9 @@ GoodBuddy 使用自己固定的 Harness Host 入口和只读组合模板,不
|
|||||||
- `$DSH_HOME/cordis.patch.yml`。
|
- `$DSH_HOME/cordis.patch.yml`。
|
||||||
- 用户 profile 的 `cordis.patch.yml`。
|
- 用户 profile 的 `cordis.patch.yml`。
|
||||||
- 任意 `--patch`。
|
- 任意 `--patch`。
|
||||||
- HMR 和动态插件安装。
|
- HMR 和 profile 驱动的动态插件安装。
|
||||||
|
|
||||||
模型名称、服务地址、工作区和非秘密策略通过严格校验的 Main 配置传给 Host。API Key 只通过受控凭据通道按需提供,不写入 YAML、命令行、Renderer 或日志。
|
模型名称、服务地址、工作区、Skills、MCP schema 和已启用插件的规范化入口通过严格校验的 Main 配置传给 Host。API Key 只通过受控凭据通道按需提供,不写入 YAML、命令行、Renderer 或日志。插件配置只来自 GoodBuddy 受管状态,不合并用户 profile 或全局补丁。
|
||||||
|
|
||||||
### 5.3 双层内部控制面
|
### 5.3 双层内部控制面
|
||||||
|
|
||||||
@@ -141,6 +145,7 @@ Renderer
|
|||||||
▼
|
▼
|
||||||
Electron Main
|
Electron Main
|
||||||
├─ RuntimeSettingsStore
|
├─ RuntimeSettingsStore
|
||||||
|
├─ RuntimeExtensionStore / npm Marketplace
|
||||||
├─ AgentRuntimeController
|
├─ AgentRuntimeController
|
||||||
├─ RuntimeAuthorizer(Ask 拒绝 / Main 代理工具授权)
|
├─ RuntimeAuthorizer(Ask 拒绝 / Main 代理工具授权)
|
||||||
└─ DeepSeekHarnessRuntime / Main Control Plane
|
└─ DeepSeekHarnessRuntime / Main Control Plane
|
||||||
@@ -152,7 +157,8 @@ Electron utilityProcess
|
|||||||
├─ GoodBuddy Harness Control Plane(内部组件)
|
├─ GoodBuddy Harness Control Plane(内部组件)
|
||||||
├─ DSH Agent 与 LLM seam
|
├─ DSH Agent 与 LLM seam
|
||||||
├─ 本地 Shell / Filesystem Provider
|
├─ 本地 Shell / Filesystem Provider
|
||||||
└─ 最小工具集
|
├─ 最小工具集与 Main 代理 MCP
|
||||||
|
└─ Main 明确启用的第三方 Cordis 插件
|
||||||
│ HTTPS
|
│ HTTPS
|
||||||
▼
|
▼
|
||||||
用户选择的 OpenAI 兼容模型连接
|
用户选择的 OpenAI 兼容模型连接
|
||||||
@@ -165,7 +171,8 @@ Electron utilityProcess
|
|||||||
| Renderer | 不可信展示层 | 脱敏设置、状态、用户可见事件 |
|
| Renderer | 不可信展示层 | 脱敏设置、状态、用户可见事件 |
|
||||||
| Preload | 窄桥 | 明确方法和共享 schema |
|
| Preload | 窄桥 | 明确方法和共享 schema |
|
||||||
| Electron Main | 可信控制面 | 加密设置、模式授权策略、Runtime 生命周期 |
|
| Electron Main | 可信控制面 | 加密设置、模式授权策略、Runtime 生命周期 |
|
||||||
| Harness utilityProcess | 不可信执行面 | 当前请求、临时凭据、受控工具和当前用户权限 |
|
| npm 安装子进程 | 第三方执行面 | 受管暂存目录、去除模型凭据的有界环境和当前用户权限 |
|
||||||
|
| Harness utilityProcess | 不可信执行面 | 当前请求、临时凭据、受控工具、第三方插件代码和当前用户权限 |
|
||||||
| Harness 工具子进程 | 最低信任 | 单次命令所需的最小环境和当前用户权限 |
|
| Harness 工具子进程 | 最低信任 | 单次命令所需的最小环境和当前用户权限 |
|
||||||
|
|
||||||
Harness 子进程崩溃、输出异常、拒绝协议或加载错误时,Main 必须失败关闭。
|
Harness 子进程崩溃、输出异常、拒绝协议或加载错误时,Main 必须失败关闭。
|
||||||
@@ -190,14 +197,21 @@ Harness 子进程崩溃、输出异常、拒绝协议或加载错误时,Main
|
|||||||
- 持久保存 API Key。
|
- 持久保存 API Key。
|
||||||
- 决定 Main 的模式授权结果。
|
- 决定 Main 的模式授权结果。
|
||||||
- 直接访问 Renderer 或 Electron API。
|
- 直接访问 Renderer 或 Electron API。
|
||||||
- 接受用户提供的插件、Host 或 profile 覆盖。
|
- 接受任意路径、外部 Host 或 profile 覆盖;插件入口只能来自 Main 的受管清单。
|
||||||
- 自行上传遥测。
|
- 自行上传遥测。
|
||||||
|
|
||||||
### 7.2 非插件约束
|
### 7.2 第三方插件加载
|
||||||
|
|
||||||
控制面不导出 `apply(ctx, config)`,不提供默认 stdin/stdout 入口,不包含 `dsh.bundle`、`cordis.patch.yml` 或可安装 manifest,也不接受 Host 之外创建的 transport。它可以保留清晰的内部模块边界以便测试和维护,但该边界不是公开扩展点。
|
GoodBuddy 控制面自身不导出 `apply(ctx, config)`,也不提供默认 stdin/stdout 入口或可安装 manifest。第三方插件由 Main 在启动配置中逐项指定:
|
||||||
|
|
||||||
若未来确有来自 GoodBuddy 真实用户、经过研究验证的扩展需求,应先重新完成产品需求、威胁模型和兼容策略评审;不得因为上游已经提供插件或市场机制而默认开放。
|
- Main 只传递受管 Store 中已启用插件的稳定 ID、规范化入口文件和 JSON 配置。
|
||||||
|
- Launcher 与 Host 对消息结构和绝对入口路径执行严格校验;Host 解析真实路径并要求入口是普通文件。
|
||||||
|
- Host 动态加载 Cordis 插件并等待激活,每个插件有独立的 5 秒激活超时,完整插件序列最多占用 90 秒。
|
||||||
|
- 插件按清单依次加载;导入、导出形态或激活失败只记录该插件,不阻止其他插件和 Host 启动。失败 Fiber 的清理同样有界。
|
||||||
|
- 失败 ID 在 ready 握手中返回 Main;Main 原子写入停用状态和启动错误。
|
||||||
|
- 插件成功激活后可注册工具或后台生命周期逻辑。Ask 只能拦截模型工具调用,不能撤销初始化阶段已经发生的副作用。
|
||||||
|
|
||||||
|
GoodBuddy 不扫描任意目录、不读取用户 profile 插件清单,也不接受 Renderer 直接提供文件路径。
|
||||||
|
|
||||||
## 8. 协议设计
|
## 8. 协议设计
|
||||||
|
|
||||||
@@ -319,24 +333,25 @@ GoodBuddy conversationId -> Harness sessionId + process generation
|
|||||||
|
|
||||||
### 10.1 模式映射
|
### 10.1 模式映射
|
||||||
|
|
||||||
| GoodBuddy 模式 | 本地工具 | GoodBuddy MCP 工具 | 行为 |
|
| GoodBuddy 模式 | Host 内置与插件工具 | GoodBuddy 自定义 MCP | 行为 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Ask | 只开放读取;分发边界阻止 `write`、`edit`、`bash` 和 `pwsh` | 不注册 | 保持只读 |
|
| Ask | 只允许已注册的 `read` 与 `skill`;其他任意工具名一律拒绝 | 不注册 | 模型工具调用保持只读 |
|
||||||
| Execute | 全部固定工具可用 | 按分配注册并经过既有 RuntimeAuthorizer | 以当前用户权限运行 |
|
| Execute | 放行 Host 中全部已注册的内置与插件工具 | 按分配注册并经过既有 RuntimeAuthorizer | 不增加逐工具审批,以当前用户权限运行 |
|
||||||
|
|
||||||
### 10.2 Ask 模式
|
### 10.2 Ask 模式
|
||||||
|
|
||||||
- Harness Control Plane 在 `tools/execute` 分发边界识别当前 Session 和在途请求。
|
- Harness Control Plane 在 `tools/execute` 分发边界识别当前 Session 和在途请求。
|
||||||
- `write`、`edit`、`bash` 与 `pwsh` 固定拒绝,不能仅靠系统提示词保持只读。
|
- 采用只读允许列表,只接受 `read` 与 `skill`;`write`、`edit`、Shell、MCP 及任意新插件工具默认拒绝,不能仅靠系统提示词保持只读。
|
||||||
- Ask 不注册 Main 代理的 MCP 工具。
|
- Ask 不注册 Main 代理的 MCP 工具。
|
||||||
- 只读不等于无限输出,读取仍受字节和工具结果上限控制。
|
- 只读不等于无限输出,读取仍受字节和工具结果上限控制。
|
||||||
- 首版不向 Ask 暴露 GoodBuddy 的可变数据工具。
|
- 插件安装脚本和 Cordis 初始化生命周期不经过 `tools/execute`。Ask 不能把已启用第三方代码变成沙箱,也不能保证第三方代码没有启动副作用。
|
||||||
|
|
||||||
### 10.3 Execute 模式
|
### 10.3 Execute 模式
|
||||||
|
|
||||||
- 工作区来自 Session 创建时的规范化绝对路径,并作为文件与命令工具的默认工作目录。
|
- 工作区来自 Session 创建时的规范化绝对路径,并作为文件与命令工具的默认工作目录。
|
||||||
- DSH 本地 Filesystem、Bash 或 PowerShell Provider 直接使用 GoodBuddy 客户端当前用户的 OS 权限。
|
- DSH 本地 Filesystem、Bash 或 PowerShell Provider 直接使用 GoodBuddy 客户端当前用户的 OS 权限。
|
||||||
- 工作区不是 containment 边界;绝对路径和命令可访问当前用户本来有权访问的主机资源。
|
- 工作区不是 containment 边界;绝对路径和命令可访问当前用户本来有权访问的主机资源。
|
||||||
|
- 已启用插件注册的工具与内置工具使用同一分发路径;GoodBuddy 不增加插件权限矩阵或逐工具确认。
|
||||||
- Main 代理的 MCP 工具继续执行分配、schema、活动请求、模式和 RuntimeAuthorizer 校验。
|
- Main 代理的 MCP 工具继续执行分配、schema、活动请求、模式和 RuntimeAuthorizer 校验。
|
||||||
- 所有工具调用仍作为活动事件记录;Ask 和 delegation 路径继续固定拒绝。
|
- 所有工具调用仍作为活动事件记录;Ask 和 delegation 路径继续固定拒绝。
|
||||||
|
|
||||||
@@ -357,6 +372,7 @@ GoodBuddy conversationId -> Harness sessionId + process generation
|
|||||||
- API Key 由 Main 从加密设置中解析。
|
- API Key 由 Main 从加密设置中解析。
|
||||||
- Harness Control Plane 只能用已握手登记的引用通过 `goodbuddy/credential/resolve` 请求当前 Runtime 的凭据。
|
- Harness Control Plane 只能用已握手登记的引用通过 `goodbuddy/credential/resolve` 请求当前 Runtime 的凭据。
|
||||||
- 凭据只在模型请求所需的子进程内存中短暂存在,不写磁盘、不进入工具环境、不打印。
|
- 凭据只在模型请求所需的子进程内存中短暂存在,不写磁盘、不进入工具环境、不打印。
|
||||||
|
- npm 安装使用同一环境白名单并移除模型 Provider 凭据;安装脚本仍拥有当前用户的文件、进程和网络权限。
|
||||||
|
|
||||||
## 11. 受控 Harness 组合
|
## 11. 受控 Harness 组合
|
||||||
|
|
||||||
@@ -370,6 +386,7 @@ GoodBuddy conversationId -> Harness sessionId + process generation
|
|||||||
- 有界的读取、写入、编辑和 Shell 工具。
|
- 有界的读取、写入、编辑和 Shell 工具。
|
||||||
- Agent scope 的 Skill Registry 与 `skill` 工具。Skill 目录由 Main 选择并在 Launcher 和 Host 两次规范化、校验。
|
- Agent scope 的 Skill Registry 与 `skill` 工具。Skill 目录由 Main 选择并在 Launcher 和 Host 两次规范化、校验。
|
||||||
- Main 代理的 MCP schema 工具。Utility 不持有 MCP URL 凭据或 Transport。
|
- Main 代理的 MCP schema 工具。Utility 不持有 MCP URL 凭据或 Transport。
|
||||||
|
- Main 明确传入的第三方 Cordis 插件及其 JSON 配置。
|
||||||
|
|
||||||
首版明确不加载:
|
首版明确不加载:
|
||||||
|
|
||||||
@@ -380,7 +397,7 @@ GoodBuddy conversationId -> Harness sessionId + process generation
|
|||||||
- Web Search、Fetch、Utility 直连 MCP、Hooks。
|
- Web Search、Fetch、Utility 直连 MCP、Hooks。
|
||||||
- Subagent、Workflow、Ralph、后台 Job。
|
- Subagent、Workflow、Ralph、后台 Job。
|
||||||
- JSONL Session Persistence 和 SQLite Session Query。
|
- JSONL Session Persistence 和 SQLite Session Query。
|
||||||
- 自动技能发现和市场技能加载。
|
- 自动技能发现、任意目录扫描和 profile 市场状态。
|
||||||
|
|
||||||
如果某个首版工具依赖被排除服务,启动审计必须失败,而不是自动加载更大的默认 bundle。
|
如果某个首版工具依赖被排除服务,启动审计必须失败,而不是自动加载更大的默认 bundle。
|
||||||
|
|
||||||
@@ -403,7 +420,7 @@ DeepSeek Harness 首版只使用符合下列边界的 GoodBuddy 模型连接:
|
|||||||
|
|
||||||
### 12.2 设置变化
|
### 12.2 设置变化
|
||||||
|
|
||||||
模型、凭据、Skill 或 MCP 分配变化时,GoodBuddy 创建新 Runtime 实例。Harness Host 路径始终由当前 GoodBuddy 构建提供,不能由设置或环境变量替换。旧实例按现有 Runtime Controller 语义退役,不在一个活动进程内热替换安全配置。
|
模型、凭据、Skill、MCP 分配或插件安装、启停、配置、移除变化时,GoodBuddy 创建新 Runtime 实例。Harness Host 路径始终由当前 GoodBuddy 构建提供,不能由设置或环境变量替换。旧实例按现有 Runtime Controller 语义退役,不在一个活动进程内热替换配置。
|
||||||
|
|
||||||
### 12.3 输入限制
|
### 12.3 输入限制
|
||||||
|
|
||||||
@@ -467,6 +484,12 @@ Host 始终由当前 GoodBuddy 版本提供,不存在自定义 Host 入口。
|
|||||||
- 状态不能只依靠绿色表达,必须同时有文字。
|
- 状态不能只依靠绿色表达,必须同时有文字。
|
||||||
- 检测中和不可用分别显示明确文案。
|
- 检测中和不可用分别显示明确文案。
|
||||||
- 高级设置默认收起。
|
- 高级设置默认收起。
|
||||||
|
- DSH 插件市场提供共享 Switch 样式的总开关并默认关闭。关闭时不请求公共 npm 目录且隐藏市场管理界面,但不修改已有插件的逐项启停状态;因此已启用插件继续随 Host 加载,重新开启后恢复原有管理状态。
|
||||||
|
- 同一 Runtime 页面提供紧凑的 DSH 插件市场:客户端筛选名称、包名、描述和许可证,已安装插件优先显示。
|
||||||
|
- 安装前使用一个明确 Checkbox 确认 npm 安装脚本、插件初始化和 Execute 工具均使用当前用户权限;不展示权限矩阵或逐工具审批。
|
||||||
|
- 已安装插件使用共享 Switch 启停,并提供 JSON 配置、明确移除确认和启动失败信息。
|
||||||
|
- npm 目录离线时仍显示并允许管理已安装插件;目录错误就地显示并可重试。
|
||||||
|
- 安装、启停、配置和移除的短期结果通过应用通知显示,不重复保留页内成功提示。
|
||||||
|
|
||||||
聊天顶栏只显示简短 Runtime 状态,不显示文件路径和版本。完整诊断只在设置页展示。
|
聊天顶栏只显示简短 Runtime 状态,不显示文件路径和版本。完整诊断只在设置页展示。
|
||||||
|
|
||||||
@@ -480,12 +503,15 @@ Host 始终由当前 GoodBuddy 版本提供,不存在自定义 Host 入口。
|
|||||||
- GoodBuddy 模型连接选择。
|
- GoodBuddy 模型连接选择。
|
||||||
- DeepSeek Harness 模型用量归属。
|
- DeepSeek Harness 模型用量归属。
|
||||||
- Skill 与 MCP 对 `deepseek-harness` 的显式分配。
|
- Skill 与 MCP 对 `deepseek-harness` 的显式分配。
|
||||||
|
- 插件市场总开关、目录、已安装状态、启停状态、JSON 配置和有界启动错误。
|
||||||
|
- 插件 `set-marketplace-enabled`、`install`、`set-enabled`、`configure`、`remove` 五类严格 action。
|
||||||
|
|
||||||
Renderer 只接收脱敏状态。任何凭据、完整环境、启动参数或内部 Cordis 配置都不能进入共享契约。
|
Renderer 只接收公开 npm 元数据和受管插件状态。任何凭据、完整环境、npm 子进程参数、内部 Host 配置或任意插件文件路径都不能由 Renderer 提交;安装 action 只能引用当前目录中的精确包名与版本。
|
||||||
|
|
||||||
已有设置迁移必须:
|
已有设置迁移必须:
|
||||||
|
|
||||||
- 对没有新字段的用户使用安全默认值。
|
- 对没有新字段的用户使用安全默认值。
|
||||||
|
- 新建或没有已安装插件的旧 Store 将市场迁移为关闭;已有安装记录的旧 Store 保持开启,避免升级后隐藏用户正在管理的插件。
|
||||||
- 保留 OpenCode、Continue 和模型连接选择。
|
- 保留 OpenCode、Continue 和模型连接选择。
|
||||||
- 修复失效的 DeepSeek Harness 模型引用时给出可报告的迁移警告。
|
- 修复失效的 DeepSeek Harness 模型引用时给出可报告的迁移警告。
|
||||||
- 不把旧 Runtime 自动迁移为 DeepSeek Harness。
|
- 不把旧 Runtime 自动迁移为 DeepSeek Harness。
|
||||||
@@ -499,30 +525,43 @@ Renderer 只接收脱敏状态。任何凭据、完整环境、启动参数或
|
|||||||
- 升级前检查 release diff、协议 diff、工具执行语义和依赖闭包。
|
- 升级前检查 release diff、协议 diff、工具执行语义和依赖闭包。
|
||||||
- 内部握手同时检查锁定的 Harness 基线和 GoodBuddy 控制协议版本。
|
- 内部握手同时检查锁定的 Harness 基线和 GoodBuddy 控制协议版本。
|
||||||
|
|
||||||
### 16.2 原生依赖
|
### 16.2 插件市场安装
|
||||||
|
|
||||||
|
- 目录来自 npm 公共搜索 API,只保留包含精确 `dsh-plugin` 关键字的包,最多读取 1,000 项并短期缓存。
|
||||||
|
- 安装时再次读取精确版本 packument,不信任搜索结果替代版本清单。
|
||||||
|
- GoodBuddy 精确锁定并随发布包携带 npm `11.19.0`;Electron 以 `ELECTRON_RUN_AS_NODE=1` 启动该 CLI 和受管 `node` shim,不要求用户另装 Node.js 或 npm。
|
||||||
|
- npm 使用普通依赖解析并运行包及依赖声明的 lifecycle scripts。安装确认必须准确说明这些脚本以当前用户权限运行。
|
||||||
|
- 安装在 Store 的暂存目录中完成,校验包名、精确版本、`dsh.bundle` 声明、入口文件和 lockfile integrity 后才原子替换当前版本。
|
||||||
|
- 市场关闭时拒绝新安装且不请求目录,但 `getEnabledExtensions()` 继续按逐项启停状态返回已安装插件。
|
||||||
|
- 首次安装默认启用。更新保留既有启停状态和 JSON 配置;失败更新保留原安装。
|
||||||
|
- 每个插件只有一个受管目录和一条状态记录;Store 同时持久化市场总开关。状态写入原子化且 mutation 串行。
|
||||||
|
- JSON 配置限制为对象根、64 KiB、16 层、每个容器 256 项和 4,096 个节点,避免 IPC、持久化和 Host 启动载荷无界增长。
|
||||||
|
- Renderer 不接收受管入口路径;Main 只接受目录中的插件 ID 与精确包版本,不能由 IPC 指定 tarball URL、文件路径或命令。
|
||||||
|
|
||||||
|
### 16.3 原生依赖
|
||||||
|
|
||||||
受控组合可能需要:
|
受控组合可能需要:
|
||||||
|
|
||||||
- `node-pty`,用于受管理的工具子进程。
|
- `node-pty`,用于受管理的工具子进程。
|
||||||
- `koffi`,用于本地 Filesystem 在 Windows 上保持文件 ACL 和原子替换。
|
- `koffi`,用于本地 Filesystem 在 Windows 上保持文件 ACL 和原子替换。
|
||||||
|
|
||||||
不得广泛批准所有安装脚本。只允许生产组合实际需要、来源已审查、版本已锁定的脚本。六个平台的构建必须验证:
|
构建 GoodBuddy 自身时不得广泛批准依赖安装脚本;只允许生产组合实际需要、来源已审查、版本已锁定的脚本。这与用户确认后由市场插件执行自身 lifecycle scripts 是两个不同阶段。六个平台的构建必须验证:
|
||||||
|
|
||||||
- 对应架构的原生文件存在。
|
- 对应架构的原生文件存在。
|
||||||
- Electron Utility Process 可加载原生模块。
|
- Electron Utility Process 可加载原生模块。
|
||||||
- spawn helper 的权限正确。
|
- spawn helper 的权限正确。
|
||||||
- 包中没有混入其他平台不需要的可执行内容,除非上游包无法拆分且已记录。
|
- 包中没有混入其他平台不需要的可执行内容,除非上游包无法拆分且已记录。
|
||||||
|
|
||||||
### 16.3 生产闭包
|
### 16.4 生产闭包
|
||||||
|
|
||||||
发布包只包含受控 Host 需要的插件和许可证。应尽量避免把 Harness Web profile、HMR 和其他未加载产品面带入生产闭包。若 npm 依赖结构无法拆分,必须:
|
发布包包含受控 Host、锁定的 npm 安装 Runtime 和许可证。应尽量避免把 Harness Web profile、HMR 和其他未加载产品面带入生产闭包。若 npm 依赖结构无法拆分,必须:
|
||||||
|
|
||||||
- 确认这些模块不会被加载。
|
- 确认这些模块不会被加载。
|
||||||
- 评估它们带来的 audit 和体积风险。
|
- 评估它们带来的 audit 和体积风险。
|
||||||
- 在后续上游版本允许时改为最小包族。
|
- 在后续上游版本允许时改为最小包族。
|
||||||
- 确认 `tests/fixtures` 以及 Web3D 测试 Skill/MCP 不进入正式发布资源。
|
- 确认 `tests/fixtures` 以及 Web3D 测试 Skill/MCP 不进入正式发布资源。
|
||||||
|
|
||||||
### 16.4 漏洞门禁
|
### 16.5 漏洞门禁
|
||||||
|
|
||||||
当前安装后的 `npm audit` 报告不能直接用 `npm audit fix --force` 处理。每项漏洞需要区分:
|
当前安装后的 `npm audit` 报告不能直接用 `npm audit fix --force` 处理。每项漏洞需要区分:
|
||||||
|
|
||||||
@@ -533,7 +572,7 @@ Renderer 只接收脱敏状态。任何凭据、完整环境、启动参数或
|
|||||||
|
|
||||||
进入 Harness 执行路径且有可利用条件的高危问题必须在发布前修复、替换或移出生产闭包。
|
进入 Harness 执行路径且有可利用条件的高危问题必须在发布前修复、替换或移出生产闭包。
|
||||||
|
|
||||||
### 16.5 发布验证
|
### 16.6 发布验证
|
||||||
|
|
||||||
`build/build-release.cjs` 需要验证:
|
`build/build-release.cjs` 需要验证:
|
||||||
|
|
||||||
@@ -542,6 +581,7 @@ Renderer 只接收脱敏状态。任何凭据、完整环境、启动参数或
|
|||||||
- 平台原生 PTY/Koffi 依赖架构正确。
|
- 平台原生 PTY/Koffi 依赖架构正确。
|
||||||
- Harness、ACP SDK 和其他新增第三方许可证已打包。
|
- Harness、ACP SDK 和其他新增第三方许可证已打包。
|
||||||
- `app.asar` 外需要执行或动态加载的资源位于预期目录。
|
- `app.asar` 外需要执行或动态加载的资源位于预期目录。
|
||||||
|
- 独立的 npm Runtime 及其捆绑依赖闭包存在,并可通过当前 Electron Node Runtime 启动和执行生命周期脚本。
|
||||||
- Web3D Skill/MCP 等测试 fixture 不在 `app.asar` 或 `extraResources` 中。
|
- Web3D Skill/MCP 等测试 fixture 不在 `app.asar` 或 `extraResources` 中。
|
||||||
|
|
||||||
## 17. 测试策略
|
## 17. 测试策略
|
||||||
@@ -552,7 +592,7 @@ Renderer 只接收脱敏状态。任何凭据、完整环境、启动参数或
|
|||||||
- 二进制检测、版本解析和路径规范化。
|
- 二进制检测、版本解析和路径规范化。
|
||||||
- ACP 握手、事件转换和请求关联。
|
- ACP 握手、事件转换和请求关联。
|
||||||
- 每个会话单请求、跨会话并行。
|
- 每个会话单请求、跨会话并行。
|
||||||
- Ask 在工具分发边界固定拒绝 Shell、写入和编辑。
|
- Ask 在工具分发边界只允许 `read` 与 `skill`,并拒绝任意新插件工具;Execute 放行插件工具。
|
||||||
- 握手只接受明确的 `execution.mode = 'host'`。
|
- 握手只接受明确的 `execution.mode = 'host'`。
|
||||||
- 未分配 Skill/MCP 不可见;分配后的 Skill catalog 可调用 `skill` 加载。
|
- 未分配 Skill/MCP 不可见;分配后的 Skill catalog 可调用 `skill` 加载。
|
||||||
- Ask 不注册 MCP 工具;Execute 每轮刷新有界 schema,并在调用前再次校验活动请求、模式、参数和 RuntimeAuthorizer 结果。
|
- Ask 不注册 MCP 工具;Execute 每轮刷新有界 schema,并在调用前再次校验活动请求、模式、参数和 RuntimeAuthorizer 结果。
|
||||||
@@ -562,6 +602,10 @@ Renderer 只接收脱敏状态。任何凭据、完整环境、启动参数或
|
|||||||
- 协议帧、事件队列、工具摘要和 stderr 上限。
|
- 协议帧、事件队列、工具摘要和 stderr 上限。
|
||||||
- release 和 dispose 的幂等性。
|
- release 和 dispose 的幂等性。
|
||||||
- 状态卡中的状态、路径、版本和当前用户执行权限。
|
- 状态卡中的状态、路径、版本和当前用户执行权限。
|
||||||
|
- 插件 action 与目录 schema 接受严格的市场总开关并拒绝权限、回滚、任意路径和非精确版本等未支持字段。
|
||||||
|
- Store 的原子安装、失败更新保留、串行 mutation、离线管理、配置、移除和启动失败停用。
|
||||||
|
- npm 分页、精确关键字、捆绑 CLI 调用、lifecycle 参数、包身份、入口和 integrity 校验。
|
||||||
|
- Renderer 的搜索、权限确认、Switch、JSON 配置、移除确认、离线目录和通知反馈。
|
||||||
|
|
||||||
### 17.2 本地集成测试
|
### 17.2 本地集成测试
|
||||||
|
|
||||||
@@ -574,6 +618,8 @@ Renderer 只接收脱敏状态。任何凭据、完整环境、启动参数或
|
|||||||
- 进程树回收。
|
- 进程树回收。
|
||||||
- 本地 Filesystem 与 Shell Provider 使用规范化工作区作为默认工作目录,且不报告沙箱强制模式。
|
- 本地 Filesystem 与 Shell Provider 使用规范化工作区作为默认工作目录,且不报告沙箱强制模式。
|
||||||
- 受控配置不会读取工作区 `.env` 和用户 DSH 配置。
|
- 受控配置不会读取工作区 `.env` 和用户 DSH 配置。
|
||||||
|
- 插件导入或激活失败相互隔离,成功插件继续加载,失败 ID 返回 Main。
|
||||||
|
- IPC 只接受严格插件 action,可信 Renderer 操作后触发 Runtime 重建。
|
||||||
|
|
||||||
### 17.3 真实模型测试
|
### 17.3 真实模型测试
|
||||||
|
|
||||||
@@ -589,6 +635,8 @@ Renderer 只接收脱敏状态。任何凭据、完整环境、启动参数或
|
|||||||
8. 释放会话和关闭应用后没有残留 Harness 或工具进程。
|
8. 释放会话和关闭应用后没有残留 Harness 或工具进程。
|
||||||
9. 从全新用户设置流程启用一个 3D 游戏 Skill 和实际本地或开放 MCP,工具事件能够证明二者确实被调用。
|
9. 从全新用户设置流程启用一个 3D 游戏 Skill 和实际本地或开放 MCP,工具事件能够证明二者确实被调用。
|
||||||
10. Harness 生成的 3D 游戏项目可以安装、启动和实际游玩,包含 3D 渲染、玩家控制、目标和反馈,浏览器无关键错误。
|
10. Harness 生成的 3D 游戏项目可以安装、启动和实际游玩,包含 3D 渲染、玩家控制、目标和反馈,浏览器无关键错误。
|
||||||
|
11. 使用公共 npm 搜索,通过 GoodBuddy 捆绑的 npm 安装经审查的最小第三方插件,Host 成功加载并执行其真实工具。
|
||||||
|
12. 实际 ACP 路径中 Ask 拒绝该插件工具,Execute 允许该工具,不出现 GoodBuddy 逐工具确认。
|
||||||
|
|
||||||
测试不得打印、快照或提交 API Key。测试创建的文件只能位于专用临时工作区,并在确认可再现后清理。
|
测试不得打印、快照或提交 API Key。测试创建的文件只能位于专用临时工作区,并在确认可再现后清理。
|
||||||
|
|
||||||
@@ -612,12 +660,16 @@ npm run build
|
|||||||
- `deepseek-harness` 可被保存、选择、检测和显示。
|
- `deepseek-harness` 可被保存、选择、检测和显示。
|
||||||
- Runtime 详情卡内显示状态、路径、版本和当前用户执行权限。
|
- Runtime 详情卡内显示状态、路径、版本和当前用户执行权限。
|
||||||
- Skills 与 MCP 设置页可把能力分配给 DeepSeek Harness,布局、键盘语义、文案和保存回显通过真机检查。
|
- Skills 与 MCP 设置页可把能力分配给 DeepSeek Harness,布局、键盘语义、文案和保存回显通过真机检查。
|
||||||
|
- DSH 市场初始关闭且不加载 npm 目录;显式开启后可以搜索、安装、启停、配置和移除插件,安装前只出现一次准确的当前用户权限确认。关闭市场后已有启用插件继续运行,重新开启后管理状态不变。
|
||||||
- Ask 写入测试在 Runtime 边界失败。
|
- Ask 写入测试在 Runtime 边界失败。
|
||||||
|
- Ask 拒绝任意插件工具,Execute 可调用全部已启用插件工具。
|
||||||
- Execute 工作区内写入成功。
|
- Execute 工作区内写入成功。
|
||||||
- Runtime OS 沙箱设置、平台 Runner、启动探测和原生沙箱打包产物均不存在。
|
- Runtime OS 沙箱设置、平台 Runner、启动探测和原生沙箱打包产物均不存在。
|
||||||
- 取消、超时、切换 Runtime 和退出应用均能回收进程。
|
- 取消、超时、切换 Runtime 和退出应用均能回收进程。
|
||||||
- 多会话不串流、不串权限请求、不串用量。
|
- 多会话不串流、不串权限请求、不串用量。
|
||||||
- 用户 DSH 配置、`.env`、遥测和 Web UI 未被加载。
|
- 用户 DSH 配置、`.env`、遥测和 Web UI 未被加载。
|
||||||
|
- 一个插件启动失败时 Host 仍可用,失败插件自动停用并在设置中显示。
|
||||||
|
- 发布包携带可执行的锁定 npm CLI,安装插件不依赖系统 Node.js/npm。
|
||||||
- API Key 不进入 Renderer、配置文件、日志、错误文本或测试产物。
|
- API Key 不进入 Renderer、配置文件、日志、错误文本或测试产物。
|
||||||
- 全量测试、类型检查、Lint 和生产构建通过。
|
- 全量测试、类型检查、Lint 和生产构建通过。
|
||||||
- 真实 OpenAI 兼容 Chat Completions 请求成功。
|
- 真实 OpenAI 兼容 Chat Completions 请求成功。
|
||||||
@@ -631,18 +683,20 @@ npm run build
|
|||||||
- 首版不恢复 Harness 原生 Session,Runtime 重启后由 GoodBuddy 历史重建。
|
- 首版不恢复 Harness 原生 Session,Runtime 重启后由 GoodBuddy 历史重建。
|
||||||
- 首版不支持图片、知识库、浏览器工具和 Harness Subagent;MCP 仅支持用户分配、Main 代理和 Execute 自动单次授权路径。
|
- 首版不支持图片、知识库、浏览器工具和 Harness Subagent;MCP 仅支持用户分配、Main 代理和 Execute 自动单次授权路径。
|
||||||
- 推理、工具和用量扩展属于 GoodBuddy 协议,不是标准 ACP 保证。
|
- 推理、工具和用量扩展属于 GoodBuddy 协议,不是标准 ACP 保证。
|
||||||
- 不支持 DSH 插件、市场包、用户 profile 或自定义 Host。
|
- 市场来自公共 npm 关键字搜索,不是精选目录;包的质量、兼容性和维护状态由发布者负责。
|
||||||
|
- 插件安装、初始化、后台生命周期和 Execute 工具使用当前用户权限,不受 Runtime OS 沙箱保护;Ask 只控制模型工具调用。
|
||||||
|
- 不支持用户 profile、自定义 Host、任意本地插件路径或 profile patch。
|
||||||
|
|
||||||
## 20. 自维护与升级策略
|
## 20. 自维护与升级策略
|
||||||
|
|
||||||
GoodBuddy 对该 Runtime 采用内部维护策略:
|
GoodBuddy 对该 Runtime 采用内部维护策略:
|
||||||
|
|
||||||
1. 当前通过验证的 Host、控制协议和依赖锁定随 GoodBuddy 一起版本化。
|
1. 当前通过验证的 Host、控制协议和依赖锁定随 GoodBuddy 一起版本化。
|
||||||
2. 不自动跟随 DSH RC、插件 ABI、profile 格式或市场元数据变化。
|
2. 不自动跟随 DSH RC、插件 ABI、profile 格式或市场元数据变化;目录只反映 npm 当前精确版本。
|
||||||
3. 升级前审查实际用户收益、上游 diff、主机工具语义、协议行为、依赖闭包和许可证。
|
3. 升级前审查实际用户收益、上游 diff、主机工具语义、协议行为、依赖闭包和许可证。
|
||||||
4. 六个平台的单元、假模型、UtilityProcess、主机执行和真实模型门禁全部通过后才能更新基线。
|
4. 六个平台的单元、假模型、UtilityProcess、主机执行和真实模型门禁全部通过后才能更新基线。
|
||||||
5. 若上游方向不再满足 GoodBuddy 用户需求或安全边界,允许维护兼容补丁、替换单个底层包,或逐步移除 DSH 依赖;`goodbuddy/*` 内部协议保持由 GoodBuddy 控制。
|
5. 若上游方向不再满足 GoodBuddy 用户需求或安全边界,允许维护兼容补丁、替换单个底层包,或逐步移除 DSH 依赖;`goodbuddy/*` 内部协议保持由 GoodBuddy 控制。
|
||||||
6. 不以进入官方插件目录、适配市场机制或服务非 GoodBuddy 客户端作为目标。
|
6. GoodBuddy 自身不以进入官方插件目录或服务非 GoodBuddy 客户端为目标;第三方市场兼容仅限当前受测 Cordis 导出和 `dsh.bundle` 声明。
|
||||||
|
|
||||||
## 21. 备选方案记录
|
## 21. 备选方案记录
|
||||||
|
|
||||||
@@ -666,4 +720,4 @@ GoodBuddy 对该 Runtime 采用内部维护策略:
|
|||||||
|
|
||||||
未采用。Main 无法可靠观察 Cordis 内部 Session、Tool、Usage 和权限 seam,只能得到不完整的外部进程行为。
|
未采用。Main 无法可靠观察 Cordis 内部 Session、Tool、Usage 和权限 seam,只能得到不完整的外部进程行为。
|
||||||
|
|
||||||
当前选择的双层内部控制面放弃标准 DSH 插件形态,只复用锁定的底层库,并维持 GoodBuddy 的可信 Main 控制权。
|
当前选择让双层内部控制面保持 GoodBuddy 私有,同时允许 Main 从受管 Store 向固定 Host 注入标准 Cordis 插件;插件扩展面不会取代 GoodBuddy 的可信 Main 控制权。
|
||||||
Generated
+1764
-39
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -66,7 +66,8 @@
|
|||||||
"compression": "maximum",
|
"compression": "maximum",
|
||||||
"files": [
|
"files": [
|
||||||
"out/**/*",
|
"out/**/*",
|
||||||
"package.json"
|
"package.json",
|
||||||
|
"!node_modules/npm{,/**/*}"
|
||||||
],
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
@@ -116,6 +117,13 @@
|
|||||||
"from": "node_modules/@agentclientprotocol/sdk/LICENSE",
|
"from": "node_modules/@agentclientprotocol/sdk/LICENSE",
|
||||||
"to": "licenses/agent-client-protocol-Apache-2.0.txt"
|
"to": "licenses/agent-client-protocol-Apache-2.0.txt"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "node_modules",
|
||||||
|
"to": "runtimes",
|
||||||
|
"filter": [
|
||||||
|
"npm{,/**/*}"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "node_modules/node-pty/LICENSE",
|
"from": "node_modules/node-pty/LICENSE",
|
||||||
"to": "licenses/node-pty-MIT.txt"
|
"to": "licenses/node-pty-MIT.txt"
|
||||||
@@ -250,6 +258,7 @@
|
|||||||
"katex": "^0.16.47",
|
"katex": "^0.16.47",
|
||||||
"lucide-react": "^1.27.0",
|
"lucide-react": "^1.27.0",
|
||||||
"mermaid": "^11.16.1",
|
"mermaid": "^11.16.1",
|
||||||
|
"npm": "11.19.0",
|
||||||
"onnxruntime-web": "^1.23.2",
|
"onnxruntime-web": "^1.23.2",
|
||||||
"pdfjs-dist": "^6.2.108",
|
"pdfjs-dist": "^6.2.108",
|
||||||
"ppu-paddle-ocr": "^6.4.0",
|
"ppu-paddle-ocr": "^6.4.0",
|
||||||
|
|||||||
@@ -313,6 +313,41 @@ describe('ContinueHostAdapter', () => {
|
|||||||
expect(launchHost).not.toHaveBeenCalled()
|
expect(launchHost).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects a custom MCP loopback capability outside Continue Agent Execute mode', async () => {
|
||||||
|
const launchHost = vi.fn()
|
||||||
|
const adapter = new ContinueHostAdapter({
|
||||||
|
binaryPath: 'C:\\unused\\cn.js',
|
||||||
|
configPath: '',
|
||||||
|
workspace: process.cwd(),
|
||||||
|
cacheRoot: 'C:\\unused\\cache',
|
||||||
|
launchHost: launchHost as unknown as ContinueHostLauncher,
|
||||||
|
modelProfile: {
|
||||||
|
id: '00000000-0000-4000-8000-000000000097',
|
||||||
|
name: 'Local model',
|
||||||
|
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||||
|
modelName: 'qwen3',
|
||||||
|
protocol: 'openai-chat-completions',
|
||||||
|
authentication: 'none'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
adapter.run(
|
||||||
|
'hello',
|
||||||
|
new AbortController().signal,
|
||||||
|
async () => 'deny',
|
||||||
|
{
|
||||||
|
workMode: 'ask',
|
||||||
|
customMcpCapability: {
|
||||||
|
endpoint: 'http://127.0.0.1:4567/mcp',
|
||||||
|
token: 'request-token'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).rejects.toThrow('仅允许在 Agent Execute 模式')
|
||||||
|
expect(launchHost).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('launches the prepared host through the injected launcher', async () => {
|
it('launches the prepared host through the injected launcher', async () => {
|
||||||
const distribution = await createDistribution()
|
const distribution = await createDistribution()
|
||||||
const skillDirectory = join(
|
const skillDirectory = join(
|
||||||
@@ -470,7 +505,18 @@ describe('ContinueHostAdapter', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
adapter.run('hello', new AbortController().signal, async () => 'deny')
|
adapter.run(
|
||||||
|
'hello',
|
||||||
|
new AbortController().signal,
|
||||||
|
async () => 'deny',
|
||||||
|
{
|
||||||
|
workMode: 'execute',
|
||||||
|
customMcpCapability: {
|
||||||
|
endpoint: 'http://127.0.0.1:4567/mcp',
|
||||||
|
token: 'request-scoped-custom-token'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
text: 'HOST_LAUNCH_OK',
|
text: 'HOST_LAUNCH_OK',
|
||||||
usage: {
|
usage: {
|
||||||
@@ -486,7 +532,7 @@ describe('ContinueHostAdapter', () => {
|
|||||||
expect(launch?.args).toEqual([
|
expect(launch?.args).toEqual([
|
||||||
'--config',
|
'--config',
|
||||||
expect.stringContaining('model-config-'),
|
expect.stringContaining('model-config-'),
|
||||||
'--readonly',
|
'--auto',
|
||||||
'serve',
|
'serve',
|
||||||
'--port',
|
'--port',
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
@@ -531,6 +577,19 @@ describe('ContinueHostAdapter', () => {
|
|||||||
apiKey: '${{ secrets.ANTHROPIC_API_KEY }}',
|
apiKey: '${{ secrets.ANTHROPIC_API_KEY }}',
|
||||||
model: 'private-model'
|
model: 'private-model'
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
name: 'goodbuddy-custom-mcp',
|
||||||
|
type: 'streamable-http',
|
||||||
|
url: 'http://127.0.0.1:4567/mcp',
|
||||||
|
requestOptions: {
|
||||||
|
headers: {
|
||||||
|
Authorization:
|
||||||
|
'Bearer request-scoped-custom-token'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
expect(generatedConfig).not.toContain('private-key')
|
expect(generatedConfig).not.toContain('private-key')
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const maximumStreamEvents = 5_000
|
|||||||
const maximumStreamEventBytes = 2 * 1024 * 1024
|
const maximumStreamEventBytes = 2 * 1024 * 1024
|
||||||
const maximumExecutionMilliseconds = 10 * 60_000
|
const maximumExecutionMilliseconds = 10 * 60_000
|
||||||
const knowledgeMcpName = 'goodbuddy-knowledge'
|
const knowledgeMcpName = 'goodbuddy-knowledge'
|
||||||
|
const customMcpName = 'goodbuddy-custom-mcp'
|
||||||
export const continueConfigurationRequiredMessage =
|
export const continueConfigurationRequiredMessage =
|
||||||
'Continue 尚未配置模型连接,请在设置中选择 GoodBuddy 模型连接或指定 Continue 配置文件'
|
'Continue 尚未配置模型连接,请在设置中选择 GoodBuddy 模型连接或指定 Continue 配置文件'
|
||||||
const utilityBootstrap = [
|
const utilityBootstrap = [
|
||||||
@@ -200,6 +201,10 @@ export type ContinueHostRunOptions = {
|
|||||||
endpoint: string
|
endpoint: string
|
||||||
token: string
|
token: string
|
||||||
}
|
}
|
||||||
|
customMcpCapability?: {
|
||||||
|
endpoint: string
|
||||||
|
token: string
|
||||||
|
}
|
||||||
onEvent?: (event: ContinueHostStreamEvent) => void | Promise<void>
|
onEvent?: (event: ContinueHostStreamEvent) => void | Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,11 +212,12 @@ type KnowledgeCapability = NonNullable<
|
|||||||
ContinueHostRunOptions['knowledgeCapability']
|
ContinueHostRunOptions['knowledgeCapability']
|
||||||
>
|
>
|
||||||
|
|
||||||
function createKnowledgeMcpServer(
|
function createLoopbackMcpServer(
|
||||||
|
name: string,
|
||||||
capability: KnowledgeCapability
|
capability: KnowledgeCapability
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
name: knowledgeMcpName,
|
name,
|
||||||
type: 'streamable-http',
|
type: 'streamable-http',
|
||||||
url: capability.endpoint,
|
url: capability.endpoint,
|
||||||
requestOptions: {
|
requestOptions: {
|
||||||
@@ -914,8 +920,35 @@ export class ContinueHostAdapter {
|
|||||||
runOptions: ContinueHostRunOptions
|
runOptions: ContinueHostRunOptions
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
const knowledgeCapability = runOptions.knowledgeCapability
|
const knowledgeCapability = runOptions.knowledgeCapability
|
||||||
|
const customMcpCapability = runOptions.customMcpCapability
|
||||||
|
if (
|
||||||
|
customMcpCapability &&
|
||||||
|
runOptions.workMode !== 'execute'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Continue 自定义 MCP 仅允许在 Agent Execute 模式使用'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const capabilityServers = [
|
||||||
|
...(knowledgeCapability
|
||||||
|
? [
|
||||||
|
createLoopbackMcpServer(
|
||||||
|
knowledgeMcpName,
|
||||||
|
knowledgeCapability
|
||||||
|
)
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(customMcpCapability
|
||||||
|
? [
|
||||||
|
createLoopbackMcpServer(
|
||||||
|
customMcpName,
|
||||||
|
customMcpCapability
|
||||||
|
)
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
]
|
||||||
if (!this.options.modelProfile) {
|
if (!this.options.modelProfile) {
|
||||||
if (!knowledgeCapability) {
|
if (capabilityServers.length === 0) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
const configured = await loadContinueConfig(
|
const configured = await loadContinueConfig(
|
||||||
@@ -942,10 +975,14 @@ export class ContinueHostAdapter {
|
|||||||
: servers.filter(
|
: servers.filter(
|
||||||
(server) =>
|
(server) =>
|
||||||
!isRecord(server) ||
|
!isRecord(server) ||
|
||||||
server.name !== knowledgeMcpName
|
(
|
||||||
|
server.name !== knowledgeMcpName &&
|
||||||
|
server.name !== customMcpName
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
retainedServers.length >= maximumConfiguredMcpServers
|
retainedServers.length + capabilityServers.length >
|
||||||
|
maximumConfiguredMcpServers
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Continue 配置文件中的 MCP Server 不能超过 ${maximumConfiguredMcpServers} 个`
|
`Continue 配置文件中的 MCP Server 不能超过 ${maximumConfiguredMcpServers} 个`
|
||||||
@@ -955,7 +992,7 @@ export class ContinueHostAdapter {
|
|||||||
...configured,
|
...configured,
|
||||||
mcpServers: [
|
mcpServers: [
|
||||||
...retainedServers,
|
...retainedServers,
|
||||||
createKnowledgeMcpServer(knowledgeCapability)
|
...capabilityServers
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -994,11 +1031,9 @@ export class ContinueHostAdapter {
|
|||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
schema: 'v1',
|
schema: 'v1',
|
||||||
models: [modelConfig],
|
models: [modelConfig],
|
||||||
...(knowledgeCapability
|
...(capabilityServers.length > 0
|
||||||
? {
|
? {
|
||||||
mcpServers: [
|
mcpServers: capabilityServers
|
||||||
createKnowledgeMcpServer(knowledgeCapability)
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
: {})
|
: {})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -298,6 +298,85 @@ describe('ContinueAgentRuntime', () => {
|
|||||||
await expect(authorize?.({ toolName: 'Bash' })).resolves.toBe('deny')
|
await expect(authorize?.({ toolName: 'Bash' })).resolves.toBe('deny')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shares assigned custom MCP with Continue Agent only in Execute through a scoped loopback token', async () => {
|
||||||
|
const gateway = {
|
||||||
|
getEndpoint: vi.fn(() => 'http://127.0.0.1:4567/mcp'),
|
||||||
|
grantCustomMcp: vi.fn(() => 'custom-capability'),
|
||||||
|
prepareCustomMcpTools: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
name: 'mcp_12345678_abcdef01_private_tool',
|
||||||
|
inputSchema: { type: 'object' }
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
revoke: vi.fn()
|
||||||
|
} as unknown as KnowledgeMcpGateway
|
||||||
|
const runtime = new ContinueAgentRuntime({
|
||||||
|
binaryPath: '',
|
||||||
|
configPath: 'C:\\safe config\\continue.yaml',
|
||||||
|
defaultWorkspace: process.cwd(),
|
||||||
|
hostCacheRoot: 'C:\\safe\\continue-host',
|
||||||
|
knowledgeGateway: gateway,
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: '00000000-0000-4000-8000-000000000094',
|
||||||
|
name: 'Private MCP',
|
||||||
|
description: '',
|
||||||
|
enabled: true,
|
||||||
|
allowDynamicTools: false,
|
||||||
|
assignments: ['continue'],
|
||||||
|
secretConfigured: true,
|
||||||
|
secret: 'must-stay-in-main',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://private.example/mcp'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
createHostAdapter: () => ({
|
||||||
|
getPreparedHost: mocks.prepareHost,
|
||||||
|
run: mocks.runHost,
|
||||||
|
dispose: mocks.disposeHost
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await collectEvents(runtime, 'execute')
|
||||||
|
|
||||||
|
expect(gateway.grantCustomMcp).toHaveBeenCalledWith(
|
||||||
|
'3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
||||||
|
expect.any(Array),
|
||||||
|
expect.any(AbortSignal)
|
||||||
|
)
|
||||||
|
expect(mocks.runHost).toHaveBeenCalledWith(
|
||||||
|
'test',
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
expect.any(Function),
|
||||||
|
{
|
||||||
|
workMode: 'execute',
|
||||||
|
customMcpCapability: {
|
||||||
|
endpoint: 'http://127.0.0.1:4567/mcp',
|
||||||
|
token: 'custom-capability'
|
||||||
|
},
|
||||||
|
onEvent: expect.any(Function)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expect(JSON.stringify(mocks.runHost.mock.calls)).not.toContain(
|
||||||
|
'must-stay-in-main'
|
||||||
|
)
|
||||||
|
expect(JSON.stringify(mocks.runHost.mock.calls)).not.toContain(
|
||||||
|
'private.example'
|
||||||
|
)
|
||||||
|
expect(gateway.revoke).toHaveBeenCalledWith('custom-capability')
|
||||||
|
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mocks.detectRuntimeBinary.mockResolvedValue({
|
||||||
|
available: true,
|
||||||
|
path: 'C:\\canonical\\cn.cmd',
|
||||||
|
version: '1.5.47',
|
||||||
|
detail: 'Continue CLI 1.5.47 已就绪'
|
||||||
|
})
|
||||||
|
mocks.runHost.mockResolvedValue({ text: 'Continue response' })
|
||||||
|
await collectEvents(runtime, 'ask')
|
||||||
|
expect(gateway.grantCustomMcp).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('adds assigned Skill instructions to the Continue prompt', async () => {
|
it('adds assigned Skill instructions to the Continue prompt', async () => {
|
||||||
let hostOptions: ContinueHostAdapterOptions | undefined
|
let hostOptions: ContinueHostAdapterOptions | undefined
|
||||||
const runtime = new ContinueAgentRuntime({
|
const runtime = new ContinueAgentRuntime({
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import type {
|
|||||||
} from './runtime'
|
} from './runtime'
|
||||||
import { detectRuntimeBinary } from './runtime-discovery'
|
import { detectRuntimeBinary } from './runtime-discovery'
|
||||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||||
import type { RuntimeSkillPackage } from '../capabilities/capability-service'
|
import type {
|
||||||
|
ResolvedMcpServer,
|
||||||
|
RuntimeSkillPackage
|
||||||
|
} from '../capabilities/capability-service'
|
||||||
import {
|
import {
|
||||||
scopedReadToolNames,
|
scopedReadToolNames,
|
||||||
type KnowledgeMcpGateway
|
type KnowledgeMcpGateway
|
||||||
@@ -39,6 +42,7 @@ export type ContinueRuntimeOptions = {
|
|||||||
launchHost?: ContinueHostLauncher
|
launchHost?: ContinueHostLauncher
|
||||||
modelProfile?: ResolvedModelProfile
|
modelProfile?: ResolvedModelProfile
|
||||||
knowledgeGateway?: KnowledgeMcpGateway
|
knowledgeGateway?: KnowledgeMcpGateway
|
||||||
|
mcpServers?: ResolvedMcpServer[]
|
||||||
createHostAdapter?: (
|
createHostAdapter?: (
|
||||||
options: ContinueHostAdapterOptions
|
options: ContinueHostAdapterOptions
|
||||||
) => Pick<
|
) => Pick<
|
||||||
@@ -293,6 +297,35 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
|||||||
token: request.knowledgeCapabilityToken
|
token: request.knowledgeCapabilityToken
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
let customMcpCapability:
|
||||||
|
| { endpoint: string; token: string }
|
||||||
|
| undefined
|
||||||
|
if (
|
||||||
|
execute &&
|
||||||
|
knowledgeEndpoint &&
|
||||||
|
this.options.mcpServers?.length
|
||||||
|
) {
|
||||||
|
const token = this.options.knowledgeGateway?.grantCustomMcp(
|
||||||
|
request.requestId,
|
||||||
|
this.options.mcpServers,
|
||||||
|
signal
|
||||||
|
)
|
||||||
|
if (token) {
|
||||||
|
customMcpCapability = {
|
||||||
|
endpoint: knowledgeEndpoint,
|
||||||
|
token
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.options.knowledgeGateway?.prepareCustomMcpTools(
|
||||||
|
token,
|
||||||
|
signal
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
this.options.knowledgeGateway?.revoke(token)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let result: ContinueHostRunResult
|
let result: ContinueHostRunResult
|
||||||
const emittedTools = new Map<string, ContinueHostTool>()
|
const emittedTools = new Map<string, ContinueHostTool>()
|
||||||
try {
|
try {
|
||||||
@@ -336,6 +369,9 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
|||||||
workMode: request.workMode,
|
workMode: request.workMode,
|
||||||
images: request.images,
|
images: request.images,
|
||||||
...(knowledgeCapability ? { knowledgeCapability } : {}),
|
...(knowledgeCapability ? { knowledgeCapability } : {}),
|
||||||
|
...(customMcpCapability
|
||||||
|
? { customMcpCapability }
|
||||||
|
: {}),
|
||||||
onEvent
|
onEvent
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -408,6 +444,12 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
|
} finally {
|
||||||
|
if (customMcpCapability) {
|
||||||
|
this.options.knowledgeGateway?.revoke(
|
||||||
|
customMcpCapability.token
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!result.text) {
|
if (!result.text) {
|
||||||
throw new Error('Continue CLI 未返回内容')
|
throw new Error('Continue CLI 未返回内容')
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type { BrowserToolService } from '../browser/browser-model-tools'
|
|||||||
import type { ModelToolProviderLike } from './model-tool-provider'
|
import type { ModelToolProviderLike } from './model-tool-provider'
|
||||||
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||||
import { ModelToolProvider } from './model-tool-provider'
|
import { ModelToolProvider } from './model-tool-provider'
|
||||||
|
import type { ControlledHarnessExtensionPackage } from './deepseek-harness-extension-loader'
|
||||||
|
|
||||||
const noSubagentTools: ModelToolProviderLike = {
|
const noSubagentTools: ModelToolProviderLike = {
|
||||||
listTools: async () => [],
|
listTools: async () => [],
|
||||||
@@ -50,6 +51,7 @@ export type AgentCapabilityContext = {
|
|||||||
bundledRuntimePaths?: BundledRuntimePaths
|
bundledRuntimePaths?: BundledRuntimePaths
|
||||||
continueHostLauncher?: ContinueHostLauncher
|
continueHostLauncher?: ContinueHostLauncher
|
||||||
deepseekHarnessLauncher?: DeepSeekHarnessRuntimeOptions['launch']
|
deepseekHarnessLauncher?: DeepSeekHarnessRuntimeOptions['launch']
|
||||||
|
deepseekHarnessExtensions?: ControlledHarnessExtensionPackage[]
|
||||||
browserService?: BrowserToolService
|
browserService?: BrowserToolService
|
||||||
knowledgeGateway?: KnowledgeMcpGateway
|
knowledgeGateway?: KnowledgeMcpGateway
|
||||||
webSearchEnabled?: boolean
|
webSearchEnabled?: boolean
|
||||||
@@ -174,6 +176,7 @@ export function createAgentRuntime(
|
|||||||
GOODBUDDY_HARNESS_MODEL_API_KEY: profile.apiKey
|
GOODBUDDY_HARNESS_MODEL_API_KEY: profile.apiKey
|
||||||
},
|
},
|
||||||
skillPackages: capabilities.skillPackages,
|
skillPackages: capabilities.skillPackages,
|
||||||
|
extensionPackages: capabilities.deepseekHarnessExtensions,
|
||||||
toolProvider: new ModelToolProvider(
|
toolProvider: new ModelToolProvider(
|
||||||
workspace,
|
workspace,
|
||||||
capabilities.mcpServers,
|
capabilities.mcpServers,
|
||||||
@@ -215,7 +218,8 @@ export function createAgentRuntime(
|
|||||||
process.env.GOODBUDDY_CONTINUE_HOST_CACHE?.trim() ??
|
process.env.GOODBUDDY_CONTINUE_HOST_CACHE?.trim() ??
|
||||||
'',
|
'',
|
||||||
launchHost: capabilities.continueHostLauncher,
|
launchHost: capabilities.continueHostLauncher,
|
||||||
knowledgeGateway: capabilities.knowledgeGateway
|
knowledgeGateway: capabilities.knowledgeGateway,
|
||||||
|
mcpServers: capabilities.mcpServers
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +250,8 @@ export function createAgentRuntime(
|
|||||||
skillInstructions: capabilities.skillInstructions,
|
skillInstructions: capabilities.skillInstructions,
|
||||||
skillPackages: capabilities.skillPackages,
|
skillPackages: capabilities.skillPackages,
|
||||||
defaultWorkspace: workspace,
|
defaultWorkspace: workspace,
|
||||||
knowledgeGateway: capabilities.knowledgeGateway
|
knowledgeGateway: capabilities.knowledgeGateway,
|
||||||
|
mcpServers: capabilities.mcpServers
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
type DeepSeekHarnessLaunchOptions
|
type DeepSeekHarnessLaunchOptions
|
||||||
} from './deepseek-harness-runtime'
|
} from './deepseek-harness-runtime'
|
||||||
import { GOODBUDDY_HARNESS_MAX_STEP_TOKENS } from './goodbuddy-harness-control-plane'
|
import { GOODBUDDY_HARNESS_MAX_STEP_TOKENS } from './goodbuddy-harness-control-plane'
|
||||||
|
import { DshNpmExtensionInstaller } from './dsh-extension-marketplace'
|
||||||
|
|
||||||
const MAX_FRAME_BYTES = 1024 * 1024
|
const MAX_FRAME_BYTES = 1024 * 1024
|
||||||
const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY'
|
const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY'
|
||||||
@@ -31,6 +32,13 @@ const SKILL_CALL_ID = 'e2e-skill-call'
|
|||||||
const MCP_CALL_ID = 'e2e-mcp-call'
|
const MCP_CALL_ID = 'e2e-mcp-call'
|
||||||
const ASK_MCP_CALL_ID = 'e2e-ask-mcp-call'
|
const ASK_MCP_CALL_ID = 'e2e-ask-mcp-call'
|
||||||
const MICRO_DELTA_COUNT = 30_000
|
const MICRO_DELTA_COUNT = 30_000
|
||||||
|
const liveModelEnabled =
|
||||||
|
process.env.GOODBUDDY_DSH_MODEL_E2E === '1'
|
||||||
|
const liveApiKey = process.env.GOODBUDDY_DSH_API_KEY ?? ''
|
||||||
|
const liveBaseUrl =
|
||||||
|
process.env.GOODBUDDY_DSH_BASE_URL ?? 'https://api.deepseek.com'
|
||||||
|
const liveModel =
|
||||||
|
process.env.GOODBUDDY_DSH_MODEL ?? 'deepseek-chat'
|
||||||
|
|
||||||
function deferred<T>() {
|
function deferred<T>() {
|
||||||
let resolvePromise!: (value: T) => void
|
let resolvePromise!: (value: T) => void
|
||||||
@@ -286,7 +294,8 @@ async function collect(
|
|||||||
|
|
||||||
function createInProcessLaunch(
|
function createInProcessLaunch(
|
||||||
dshHome: string,
|
dshHome: string,
|
||||||
model: HarnessModel
|
model?: HarnessModel,
|
||||||
|
observeStream?: (options: GenerateOptions) => void
|
||||||
): {
|
): {
|
||||||
launch(
|
launch(
|
||||||
options: DeepSeekHarnessLaunchOptions
|
options: DeepSeekHarnessLaunchOptions
|
||||||
@@ -315,6 +324,7 @@ function createInProcessLaunch(
|
|||||||
harnessVersion: '0.1.0-rc.6',
|
harnessVersion: '0.1.0-rc.6',
|
||||||
credentialRefs: options.credentialRefs,
|
credentialRefs: options.credentialRefs,
|
||||||
skillPackages: options.skillPackages,
|
skillPackages: options.skillPackages,
|
||||||
|
extensionPackages: options.extensionPackages,
|
||||||
stream: createBoundedNdJsonStream(
|
stream: createBoundedNdJsonStream(
|
||||||
hostToClient.writable,
|
hostToClient.writable,
|
||||||
clientToHost.readable,
|
clientToHost.readable,
|
||||||
@@ -322,11 +332,23 @@ function createInProcessLaunch(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
hosts.push(host)
|
hosts.push(host)
|
||||||
|
if (observeStream) {
|
||||||
|
host.context.on(
|
||||||
|
'llm/stream',
|
||||||
|
(request, next) => {
|
||||||
|
observeStream(request)
|
||||||
|
return next()
|
||||||
|
},
|
||||||
|
{ global: true, prepend: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (model) {
|
||||||
host.context.on(
|
host.context.on(
|
||||||
'llm/stream',
|
'llm/stream',
|
||||||
(request) => model.stream(request),
|
(request) => model.stream(request),
|
||||||
{ global: true, prepend: true }
|
{ global: true, prepend: true }
|
||||||
)
|
)
|
||||||
|
}
|
||||||
let terminated = false
|
let terminated = false
|
||||||
return {
|
return {
|
||||||
stdin: clientToHost.writable,
|
stdin: clientToHost.writable,
|
||||||
@@ -661,7 +683,9 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
|||||||
expect(fakeModel.askToolNames).not.toContain(
|
expect(fakeModel.askToolNames).not.toContain(
|
||||||
fakeModel.mcpToolName
|
fakeModel.mcpToolName
|
||||||
)
|
)
|
||||||
expect(fakeModel.askToolResult).toContain('unknown tool')
|
expect(fakeModel.askToolResult).toContain(
|
||||||
|
'Ask 模式不允许执行非只读工具'
|
||||||
|
)
|
||||||
expect(callTool).toHaveBeenCalledTimes(callsBeforeAsk)
|
expect(callTool).toHaveBeenCalledTimes(callsBeforeAsk)
|
||||||
expect(listTools).toHaveBeenCalledTimes(listsBeforeAsk)
|
expect(listTools).toHaveBeenCalledTimes(listsBeforeAsk)
|
||||||
expect(authorize).toHaveBeenCalledTimes(approvalsBeforeAsk)
|
expect(authorize).toHaveBeenCalledTimes(approvalsBeforeAsk)
|
||||||
@@ -697,4 +721,197 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
|||||||
},
|
},
|
||||||
60_000
|
60_000
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.runIf(liveModelEnabled)(
|
||||||
|
'rejects a real npm plugin in Ask and lets a real model call it in Execute',
|
||||||
|
async () => {
|
||||||
|
if (!liveApiKey) {
|
||||||
|
throw new Error(
|
||||||
|
'GOODBUDDY_DSH_API_KEY is required for live DSH model E2E'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const root = await realpath(
|
||||||
|
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-plugin-model-'))
|
||||||
|
)
|
||||||
|
const workspace = join(root, 'workspace')
|
||||||
|
const dshHome = join(root, 'dsh-home')
|
||||||
|
const installation = join(root, 'extension')
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(workspace),
|
||||||
|
mkdir(dshHome),
|
||||||
|
mkdir(installation)
|
||||||
|
])
|
||||||
|
const entry = {
|
||||||
|
id: 'dsh-plugin-greet-live',
|
||||||
|
package: {
|
||||||
|
name: 'dsh-plugin-greet',
|
||||||
|
version: '0.1.0'
|
||||||
|
},
|
||||||
|
displayName: 'dsh-plugin-greet',
|
||||||
|
description: 'Reviewed minimal live DSH plugin fixture.'
|
||||||
|
}
|
||||||
|
const npmCliPath = process.env.GOODBUDDY_DSH_NPM_CLI
|
||||||
|
? resolve(process.env.GOODBUDDY_DSH_NPM_CLI)
|
||||||
|
: resolve(
|
||||||
|
'node_modules',
|
||||||
|
'npm',
|
||||||
|
'bin',
|
||||||
|
'npm-cli.js'
|
||||||
|
)
|
||||||
|
const nodeExecutablePath =
|
||||||
|
process.env.GOODBUDDY_DSH_NODE_EXECUTABLE
|
||||||
|
? resolve(process.env.GOODBUDDY_DSH_NODE_EXECUTABLE)
|
||||||
|
: undefined
|
||||||
|
const installer = new DshNpmExtensionInstaller({
|
||||||
|
dshHome,
|
||||||
|
npmCliPath,
|
||||||
|
...(nodeExecutablePath ? { nodeExecutablePath } : {})
|
||||||
|
})
|
||||||
|
const installed = await installer.install({
|
||||||
|
entry,
|
||||||
|
destinationDirectory: installation
|
||||||
|
})
|
||||||
|
const observedRequests: GenerateOptions[] = []
|
||||||
|
const inProcess = createInProcessLaunch(
|
||||||
|
dshHome,
|
||||||
|
undefined,
|
||||||
|
(options) => observedRequests.push(options)
|
||||||
|
)
|
||||||
|
const runtime = new DeepSeekHarnessRuntime({
|
||||||
|
defaultWorkspace: workspace,
|
||||||
|
baseUrl: liveBaseUrl,
|
||||||
|
model: liveModel,
|
||||||
|
launch: (options) => inProcess.launch(options),
|
||||||
|
credentialRefs: {
|
||||||
|
[CREDENTIAL_REF]: liveApiKey
|
||||||
|
},
|
||||||
|
extensionPackages: [
|
||||||
|
{
|
||||||
|
id: entry.id,
|
||||||
|
entrypoint: join(
|
||||||
|
installation,
|
||||||
|
...installed.entrypoint.split('/')
|
||||||
|
),
|
||||||
|
configuration: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
initializationTimeoutMs: 20_000,
|
||||||
|
promptTimeoutMs: 120_000,
|
||||||
|
shutdownTimeoutMs: 5_000
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const askEvents = await collect(
|
||||||
|
runtime.run(
|
||||||
|
{
|
||||||
|
requestId: 'request-live-plugin-ask',
|
||||||
|
conversationId: 'live-plugin-ask',
|
||||||
|
prompt:
|
||||||
|
'DSH_ASK_PLUGIN_PROBE: attempt to call greet exactly once with name GoodBuddyAsk. The runtime must reject it. After the tool result, reply with DSH_ASK_PLUGIN_BLOCKED.',
|
||||||
|
workMode: 'ask'
|
||||||
|
},
|
||||||
|
new AbortController().signal
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const askRequests = observedRequests.filter((options) =>
|
||||||
|
latestUserText(options).includes(
|
||||||
|
'DSH_ASK_PLUGIN_PROBE'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expect(askRequests.length).toBeGreaterThan(0)
|
||||||
|
expect(
|
||||||
|
askRequests.flatMap(
|
||||||
|
(options) =>
|
||||||
|
options.tools?.map((tool) => tool.name) ?? []
|
||||||
|
)
|
||||||
|
).toContain('greet')
|
||||||
|
expect(askEvents).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'tool',
|
||||||
|
name: 'greet',
|
||||||
|
state: 'pending'
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'tool',
|
||||||
|
state: 'failed',
|
||||||
|
output: expect.stringContaining(
|
||||||
|
'Ask 模式不允许执行非只读工具'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ type: 'done' })
|
||||||
|
])
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
askEvents.some(
|
||||||
|
(event) =>
|
||||||
|
event.type === 'tool' &&
|
||||||
|
event.state === 'completed'
|
||||||
|
)
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
askEvents
|
||||||
|
.flatMap((event) =>
|
||||||
|
event.type === 'text' ? [event.delta] : []
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
).toContain('DSH_ASK_PLUGIN_BLOCKED')
|
||||||
|
|
||||||
|
const executeEvents = await collect(
|
||||||
|
runtime.run(
|
||||||
|
{
|
||||||
|
requestId: 'request-live-plugin-execute',
|
||||||
|
conversationId: 'live-plugin-execute',
|
||||||
|
prompt:
|
||||||
|
'DSH_EXECUTE_PLUGIN_PROBE: call greet exactly once with name GoodBuddyLive. After its result, reply with DSH_EXECUTE_PLUGIN_OK and the exact greeting.',
|
||||||
|
workMode: 'execute'
|
||||||
|
},
|
||||||
|
new AbortController().signal
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const executeRequests = observedRequests.filter((options) =>
|
||||||
|
latestUserText(options).includes(
|
||||||
|
'DSH_EXECUTE_PLUGIN_PROBE'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expect(executeRequests.length).toBeGreaterThan(0)
|
||||||
|
expect(
|
||||||
|
executeRequests.some((options) =>
|
||||||
|
options.tools?.some((tool) => tool.name === 'greet')
|
||||||
|
)
|
||||||
|
).toBe(true)
|
||||||
|
expect(executeEvents).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'tool',
|
||||||
|
name: 'greet',
|
||||||
|
state: 'pending'
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'tool',
|
||||||
|
state: 'completed',
|
||||||
|
output: expect.stringContaining(
|
||||||
|
'Hello, GoodBuddyLive!'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ type: 'done' })
|
||||||
|
])
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
executeEvents
|
||||||
|
.flatMap((event) =>
|
||||||
|
event.type === 'text' ? [event.delta] : []
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
).toContain('DSH_EXECUTE_PLUGIN_OK')
|
||||||
|
} finally {
|
||||||
|
await runtime.dispose()
|
||||||
|
await Promise.allSettled(
|
||||||
|
inProcess.hosts.map((host) => host.dispose())
|
||||||
|
)
|
||||||
|
await rm(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
180_000
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { isAbsolute } from 'node:path'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { isDeepSeekHarnessCompatibleBaseUrl } from '../../shared/deepseek-harness-compatibility'
|
||||||
|
import {
|
||||||
|
runtimeExtensionConfigurationSchema,
|
||||||
|
runtimeExtensionIdSchema
|
||||||
|
} from '../../shared/runtime-extension-contracts'
|
||||||
|
|
||||||
|
export const DEEPSEEK_HARNESS_CONTROL_PROTOCOL =
|
||||||
|
'goodbuddy.deepseek-harness.control'
|
||||||
|
export const DEEPSEEK_HARNESS_CONTROL_VERSION = 1
|
||||||
|
export const DEEPSEEK_HARNESS_HOST_VERSION = '0.1.0-rc.6'
|
||||||
|
export const DEEPSEEK_HARNESS_CREDENTIAL_REF =
|
||||||
|
'GOODBUDDY_HARNESS_MODEL_API_KEY'
|
||||||
|
|
||||||
|
const skillPackageSchema = z
|
||||||
|
.object({
|
||||||
|
id: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(128)
|
||||||
|
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u),
|
||||||
|
directory: z.string().min(1).max(32_768).refine(isAbsolute)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
const extensionPackageSchema = z
|
||||||
|
.object({
|
||||||
|
id: runtimeExtensionIdSchema,
|
||||||
|
entrypoint: z.string().min(1).max(32_768).refine(isAbsolute),
|
||||||
|
configuration: runtimeExtensionConfigurationSchema
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const controlledHarnessHostConfigSchema = z
|
||||||
|
.object({
|
||||||
|
workspace: z.string().min(1).max(32_768).refine(isAbsolute),
|
||||||
|
dshHome: z.string().min(1).max(32_768).refine(isAbsolute),
|
||||||
|
baseUrl: z
|
||||||
|
.url()
|
||||||
|
.max(2_048)
|
||||||
|
.refine(isDeepSeekHarnessCompatibleBaseUrl),
|
||||||
|
api: z.literal('openai-completions'),
|
||||||
|
provider: z.literal('goodbuddy'),
|
||||||
|
model: z.string().min(1).max(128),
|
||||||
|
harnessVersion: z.literal(DEEPSEEK_HARNESS_HOST_VERSION),
|
||||||
|
credentialRefs: z
|
||||||
|
.tuple([z.literal(DEEPSEEK_HARNESS_CREDENTIAL_REF)])
|
||||||
|
.readonly(),
|
||||||
|
skillPackages: z.array(skillPackageSchema).max(64),
|
||||||
|
extensionPackages: z.array(extensionPackageSchema).max(64),
|
||||||
|
maxFrameBytes: z.literal(1024 * 1024)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type ControlledHarnessBootstrapConfig = z.infer<
|
||||||
|
typeof controlledHarnessHostConfigSchema
|
||||||
|
>
|
||||||
|
|
||||||
|
export type DeepSeekHarnessControlMessage =
|
||||||
|
| {
|
||||||
|
protocol: typeof DEEPSEEK_HARNESS_CONTROL_PROTOCOL
|
||||||
|
version: typeof DEEPSEEK_HARNESS_CONTROL_VERSION
|
||||||
|
type: 'start'
|
||||||
|
config: ControlledHarnessBootstrapConfig
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
protocol: typeof DEEPSEEK_HARNESS_CONTROL_PROTOCOL
|
||||||
|
version: typeof DEEPSEEK_HARNESS_CONTROL_VERSION
|
||||||
|
type: 'ready'
|
||||||
|
failedExtensionIds: readonly string[]
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
protocol: typeof DEEPSEEK_HARNESS_CONTROL_PROTOCOL
|
||||||
|
version: typeof DEEPSEEK_HARNESS_CONTROL_VERSION
|
||||||
|
type: 'fatal'
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHarnessControlMessage(
|
||||||
|
value: unknown
|
||||||
|
): DeepSeekHarnessControlMessage | undefined {
|
||||||
|
if (
|
||||||
|
!value ||
|
||||||
|
typeof value !== 'object' ||
|
||||||
|
Array.isArray(value)
|
||||||
|
) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>
|
||||||
|
if (
|
||||||
|
record.protocol !== DEEPSEEK_HARNESS_CONTROL_PROTOCOL ||
|
||||||
|
record.version !== DEEPSEEK_HARNESS_CONTROL_VERSION
|
||||||
|
) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
record.type === 'ready' &&
|
||||||
|
Object.keys(record).length === 4 &&
|
||||||
|
Array.isArray(record.failedExtensionIds)
|
||||||
|
) {
|
||||||
|
const failedExtensionIds = z
|
||||||
|
.array(runtimeExtensionIdSchema)
|
||||||
|
.max(64)
|
||||||
|
.safeParse(record.failedExtensionIds)
|
||||||
|
return failedExtensionIds.success
|
||||||
|
? {
|
||||||
|
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||||
|
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||||
|
type: 'ready',
|
||||||
|
failedExtensionIds: failedExtensionIds.data
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
record.type === 'fatal' &&
|
||||||
|
Object.keys(record).length === 4 &&
|
||||||
|
typeof record.code === 'string' &&
|
||||||
|
/^[A-Z][A-Z0-9_]{0,63}$/u.test(record.code)
|
||||||
|
) {
|
||||||
|
return record as DeepSeekHarnessControlMessage
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
record.type === 'start' &&
|
||||||
|
Object.keys(record).length === 4
|
||||||
|
) {
|
||||||
|
const parsed = controlledHarnessHostConfigSchema.safeParse(
|
||||||
|
record.config
|
||||||
|
)
|
||||||
|
return parsed.success
|
||||||
|
? {
|
||||||
|
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||||
|
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||||
|
type: 'start',
|
||||||
|
config: parsed.data
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { Context } from '@deepseek-ai/cordis'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
loadControlledHarnessExtensions,
|
||||||
|
type ControlledHarnessExtensionPackage
|
||||||
|
} from './deepseek-harness-extension-loader'
|
||||||
|
|
||||||
|
function extension(
|
||||||
|
id: string
|
||||||
|
): ControlledHarnessExtensionPackage {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
entrypoint: `C:\\extensions\\${id}\\index.js`,
|
||||||
|
configuration: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DeepSeek Harness extension loader', () => {
|
||||||
|
it('loads named Cordis plugin exports and keeps working extensions active', async () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
const apply = vi.fn()
|
||||||
|
const result = await loadControlledHarnessExtensions(
|
||||||
|
ctx,
|
||||||
|
[extension('greet')],
|
||||||
|
{
|
||||||
|
importModule: vi.fn(async () => ({
|
||||||
|
name: 'greet',
|
||||||
|
apply
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
loadedIds: ['greet'],
|
||||||
|
failedIds: [],
|
||||||
|
failures: []
|
||||||
|
})
|
||||||
|
expect(apply).toHaveBeenCalledOnce()
|
||||||
|
await ctx.fiber.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('continues after one extension fails to import', async () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
const importModule = vi.fn(async (url: string) => {
|
||||||
|
if (url.includes('broken')) {
|
||||||
|
throw new Error('broken extension')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
apply() {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loadControlledHarnessExtensions(
|
||||||
|
ctx,
|
||||||
|
[extension('broken'), extension('working')],
|
||||||
|
{ importModule }
|
||||||
|
)
|
||||||
|
).resolves.toEqual({
|
||||||
|
loadedIds: ['working'],
|
||||||
|
failedIds: ['broken'],
|
||||||
|
failures: [
|
||||||
|
{
|
||||||
|
id: 'broken',
|
||||||
|
message: 'broken extension'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
await ctx.fiber.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disposes an extension whose activation fails', async () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
const dispose = vi.spyOn(ctx.fiber, 'dispose')
|
||||||
|
|
||||||
|
const result = await loadControlledHarnessExtensions(
|
||||||
|
ctx,
|
||||||
|
[extension('broken')],
|
||||||
|
{
|
||||||
|
importModule: async () => ({
|
||||||
|
apply() {
|
||||||
|
throw new Error('activation failed')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
loadedIds: [],
|
||||||
|
failedIds: ['broken'],
|
||||||
|
failures: [
|
||||||
|
{
|
||||||
|
id: 'broken',
|
||||||
|
message: 'activation failed'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
expect(dispose).not.toHaveBeenCalled()
|
||||||
|
await ctx.fiber.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bounds the complete extension startup sequence', async () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
const importModule = vi.fn(
|
||||||
|
() => new Promise<never>(() => undefined)
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await loadControlledHarnessExtensions(
|
||||||
|
ctx,
|
||||||
|
[extension('slow'), extension('later')],
|
||||||
|
{
|
||||||
|
activationTimeoutMs: 1_000,
|
||||||
|
totalActivationTimeoutMs: 20,
|
||||||
|
importModule
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(importModule).toHaveBeenCalledOnce()
|
||||||
|
expect(result.loadedIds).toEqual([])
|
||||||
|
expect(result.failedIds).toEqual(['slow', 'later'])
|
||||||
|
expect(result.failures[0]?.message).toContain('timed out')
|
||||||
|
expect(result.failures[1]?.message).toContain(
|
||||||
|
'startup deadline exceeded'
|
||||||
|
)
|
||||||
|
await ctx.fiber.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not activate an import that resolves after its timeout', async () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
const apply = vi.fn()
|
||||||
|
let resolveImport!: (module: {
|
||||||
|
apply: typeof apply
|
||||||
|
}) => void
|
||||||
|
const imported = new Promise<{ apply: typeof apply }>(
|
||||||
|
(resolve) => {
|
||||||
|
resolveImport = resolve
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await loadControlledHarnessExtensions(
|
||||||
|
ctx,
|
||||||
|
[extension('late')],
|
||||||
|
{
|
||||||
|
activationTimeoutMs: 10,
|
||||||
|
totalActivationTimeoutMs: 100,
|
||||||
|
importModule: () => imported
|
||||||
|
}
|
||||||
|
)
|
||||||
|
resolveImport({ apply })
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
expect(apply).not.toHaveBeenCalled()
|
||||||
|
await ctx.fiber.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import type { Context, Fiber, Plugin } from '@deepseek-ai/cordis'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
|
const DEFAULT_ACTIVATION_TIMEOUT_MS = 5_000
|
||||||
|
const DEFAULT_TOTAL_ACTIVATION_TIMEOUT_MS = 90_000
|
||||||
|
const DISPOSAL_TIMEOUT_MS = 1_000
|
||||||
|
|
||||||
|
export type ControlledHarnessExtensionPackage = {
|
||||||
|
id: string
|
||||||
|
entrypoint: string
|
||||||
|
configuration: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ControlledHarnessExtensionLoadResult = {
|
||||||
|
loadedIds: string[]
|
||||||
|
failedIds: string[]
|
||||||
|
failures: Array<{ id: string; message: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtensionModule = {
|
||||||
|
default?: unknown
|
||||||
|
apply?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the import native so Vite does not try to resolve userData file URLs
|
||||||
|
// while bundling or running Vitest.
|
||||||
|
const nativeImportModule = new Function(
|
||||||
|
'specifier',
|
||||||
|
'return import(specifier)'
|
||||||
|
) as (specifier: string) => Promise<ExtensionModule>
|
||||||
|
const requireExtension = createRequire(import.meta.url)
|
||||||
|
|
||||||
|
async function defaultImportModule(
|
||||||
|
specifier: string
|
||||||
|
): Promise<ExtensionModule> {
|
||||||
|
try {
|
||||||
|
return requireExtension(
|
||||||
|
fileURLToPath(specifier)
|
||||||
|
) as ExtensionModule
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
error &&
|
||||||
|
typeof error === 'object' &&
|
||||||
|
'code' in error &&
|
||||||
|
typeof error.code === 'string'
|
||||||
|
? error.code
|
||||||
|
: undefined
|
||||||
|
if (
|
||||||
|
code !== 'ERR_REQUIRE_ASYNC_MODULE' &&
|
||||||
|
code !== 'ERR_REQUIRE_ESM'
|
||||||
|
) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return nativeImportModule(specifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlugin(value: unknown): value is Plugin {
|
||||||
|
return (
|
||||||
|
typeof value === 'function' ||
|
||||||
|
(value !== null &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
typeof (value as { apply?: unknown }).apply === 'function')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePlugin(module: ExtensionModule): Plugin {
|
||||||
|
if (isPlugin(module)) {
|
||||||
|
return module
|
||||||
|
}
|
||||||
|
if (isPlugin(module.default)) {
|
||||||
|
return module.default
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
'DeepSeek Harness extension must export a Cordis plugin'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTimeout<T>(
|
||||||
|
operation: PromiseLike<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
message: string,
|
||||||
|
onTimeout?: () => void
|
||||||
|
): Promise<T> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
Promise.resolve(operation),
|
||||||
|
new Promise<never>((_resolve, reject) => {
|
||||||
|
timer = setTimeout(
|
||||||
|
() => {
|
||||||
|
onTimeout?.()
|
||||||
|
reject(new Error(message))
|
||||||
|
},
|
||||||
|
timeoutMs
|
||||||
|
)
|
||||||
|
})
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadControlledHarnessExtensions(
|
||||||
|
ctx: Context,
|
||||||
|
extensions: readonly ControlledHarnessExtensionPackage[],
|
||||||
|
options: {
|
||||||
|
activationTimeoutMs?: number
|
||||||
|
totalActivationTimeoutMs?: number
|
||||||
|
importModule?: (url: string) => Promise<ExtensionModule>
|
||||||
|
} = {}
|
||||||
|
): Promise<ControlledHarnessExtensionLoadResult> {
|
||||||
|
const loadedIds: string[] = []
|
||||||
|
const failedIds: string[] = []
|
||||||
|
const failures: Array<{ id: string; message: string }> = []
|
||||||
|
const activationTimeoutMs =
|
||||||
|
options.activationTimeoutMs ?? DEFAULT_ACTIVATION_TIMEOUT_MS
|
||||||
|
const deadline =
|
||||||
|
Date.now() +
|
||||||
|
(options.totalActivationTimeoutMs ??
|
||||||
|
DEFAULT_TOTAL_ACTIVATION_TIMEOUT_MS)
|
||||||
|
const importModule = options.importModule ?? defaultImportModule
|
||||||
|
|
||||||
|
for (const extension of extensions) {
|
||||||
|
let fiber: (Fiber & PromiseLike<Fiber>) | undefined
|
||||||
|
let acceptActivation = true
|
||||||
|
try {
|
||||||
|
const remainingMs = deadline - Date.now()
|
||||||
|
if (remainingMs <= 0) {
|
||||||
|
throw new Error(
|
||||||
|
'DeepSeek Harness extension startup deadline exceeded'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await withTimeout(
|
||||||
|
(async () => {
|
||||||
|
const module = await importModule(
|
||||||
|
pathToFileURL(extension.entrypoint).href
|
||||||
|
)
|
||||||
|
if (!acceptActivation) {
|
||||||
|
throw new Error(
|
||||||
|
'DeepSeek Harness extension activation timed out'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const plugin = resolvePlugin(module)
|
||||||
|
fiber = ctx.plugin(plugin, extension.configuration)
|
||||||
|
await Promise.resolve(fiber)
|
||||||
|
})(),
|
||||||
|
Math.max(1, Math.min(activationTimeoutMs, remainingMs)),
|
||||||
|
'DeepSeek Harness extension activation timed out',
|
||||||
|
() => {
|
||||||
|
acceptActivation = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
loadedIds.push(extension.id)
|
||||||
|
} catch (error) {
|
||||||
|
if (fiber) {
|
||||||
|
await withTimeout(
|
||||||
|
fiber.dispose(),
|
||||||
|
DISPOSAL_TIMEOUT_MS,
|
||||||
|
'DeepSeek Harness extension disposal timed out'
|
||||||
|
).catch(() => undefined)
|
||||||
|
}
|
||||||
|
failedIds.push(extension.id)
|
||||||
|
failures.push({
|
||||||
|
id: extension.id,
|
||||||
|
message:
|
||||||
|
error instanceof Error && error.message.trim()
|
||||||
|
? error.message.slice(0, 1_000)
|
||||||
|
: 'DeepSeek Harness extension failed to start'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { loadedIds, failedIds, failures }
|
||||||
|
}
|
||||||
@@ -400,7 +400,8 @@ describe('DeepSeekHarnessRuntime', () => {
|
|||||||
baseUrl: 'https://api.deepseek.com',
|
baseUrl: 'https://api.deepseek.com',
|
||||||
model: 'deepseek-test',
|
model: 'deepseek-test',
|
||||||
credentialRefs: [],
|
credentialRefs: [],
|
||||||
skillPackages: []
|
skillPackages: [],
|
||||||
|
extensionPackages: []
|
||||||
})
|
})
|
||||||
expect(harness.requests).toContainEqual({
|
expect(harness.requests).toContainEqual({
|
||||||
method: 'goodbuddy/session/prepare',
|
method: 'goodbuddy/session/prepare',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
} from './runtime'
|
} from './runtime'
|
||||||
import type { ModelToolProviderLike } from './model-tool-provider'
|
import type { ModelToolProviderLike } from './model-tool-provider'
|
||||||
import type { RuntimeSkillPackage } from '../capabilities/capability-service'
|
import type { RuntimeSkillPackage } from '../capabilities/capability-service'
|
||||||
|
import type { ControlledHarnessExtensionPackage } from './deepseek-harness-extension-loader'
|
||||||
import {
|
import {
|
||||||
assertObjectJsonSchema,
|
assertObjectJsonSchema,
|
||||||
validateJsonSchemaValue
|
validateJsonSchemaValue
|
||||||
@@ -137,6 +138,7 @@ export type DeepSeekHarnessLaunchOptions = {
|
|||||||
model: string
|
model: string
|
||||||
credentialRefs: readonly string[]
|
credentialRefs: readonly string[]
|
||||||
skillPackages: readonly RuntimeSkillPackage[]
|
skillPackages: readonly RuntimeSkillPackage[]
|
||||||
|
extensionPackages: readonly ControlledHarnessExtensionPackage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeepSeekHarnessRuntimeOptions = {
|
export type DeepSeekHarnessRuntimeOptions = {
|
||||||
@@ -154,6 +156,7 @@ export type DeepSeekHarnessRuntimeOptions = {
|
|||||||
maxRequestOutputCharacters?: number
|
maxRequestOutputCharacters?: number
|
||||||
credentialRefs?: Readonly<Record<string, string>>
|
credentialRefs?: Readonly<Record<string, string>>
|
||||||
skillPackages?: RuntimeSkillPackage[]
|
skillPackages?: RuntimeSkillPackage[]
|
||||||
|
extensionPackages?: ControlledHarnessExtensionPackage[]
|
||||||
toolProvider?: ModelToolProviderLike
|
toolProvider?: ModelToolProviderLike
|
||||||
loadAcpSdk?: () => Promise<DeepSeekHarnessAcpSdk>
|
loadAcpSdk?: () => Promise<DeepSeekHarnessAcpSdk>
|
||||||
}
|
}
|
||||||
@@ -694,7 +697,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
|||||||
credentialRefs: Object.keys(
|
credentialRefs: Object.keys(
|
||||||
this.options.credentialRefs ?? {}
|
this.options.credentialRefs ?? {}
|
||||||
),
|
),
|
||||||
skillPackages: this.options.skillPackages ?? []
|
skillPackages: this.options.skillPackages ?? [],
|
||||||
|
extensionPackages: this.options.extensionPackages ?? []
|
||||||
}),
|
}),
|
||||||
this.initializationTimeoutMs,
|
this.initializationTimeoutMs,
|
||||||
'启动'
|
'启动'
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ async function fixture() {
|
|||||||
writeFile(hostPath, '', 'utf8')
|
writeFile(hostPath, '', 'utf8')
|
||||||
])
|
])
|
||||||
return {
|
return {
|
||||||
|
root,
|
||||||
dshHome,
|
dshHome,
|
||||||
hostPath,
|
hostPath,
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
@@ -52,7 +53,8 @@ async function fixture() {
|
|||||||
baseUrl: 'https://gateway.example/openai/v1',
|
baseUrl: 'https://gateway.example/openai/v1',
|
||||||
model: 'qwen-plus',
|
model: 'qwen-plus',
|
||||||
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
|
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
|
||||||
skillPackages: []
|
skillPackages: [],
|
||||||
|
extensionPackages: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,7 +65,8 @@ describe('DeepSeek Harness utility launcher', () => {
|
|||||||
parseHarnessControlMessage({
|
parseHarnessControlMessage({
|
||||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||||
type: 'ready'
|
type: 'ready',
|
||||||
|
failedExtensionIds: []
|
||||||
})
|
})
|
||||||
).toMatchObject({ type: 'ready' })
|
).toMatchObject({ type: 'ready' })
|
||||||
expect(
|
expect(
|
||||||
@@ -71,6 +74,7 @@ describe('DeepSeek Harness utility launcher', () => {
|
|||||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||||
type: 'ready',
|
type: 'ready',
|
||||||
|
failedExtensionIds: [],
|
||||||
apiKey: 'must-not-pass'
|
apiKey: 'must-not-pass'
|
||||||
})
|
})
|
||||||
).toBeUndefined()
|
).toBeUndefined()
|
||||||
@@ -105,7 +109,8 @@ describe('DeepSeek Harness utility launcher', () => {
|
|||||||
utility.emit('message', {
|
utility.emit('message', {
|
||||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||||
type: 'ready'
|
type: 'ready',
|
||||||
|
failedExtensionIds: []
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect(launching).resolves.toMatchObject({
|
await expect(launching).resolves.toMatchObject({
|
||||||
@@ -122,6 +127,87 @@ describe('DeepSeek Harness utility launcher', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('persists extension startup failures before exposing the child', async () => {
|
||||||
|
const { root, dshHome, hostPath, launchOptions } =
|
||||||
|
await fixture()
|
||||||
|
const entrypoint = join(root, 'greet.mjs')
|
||||||
|
await writeFile(entrypoint, 'export function apply() {}\n', 'utf8')
|
||||||
|
const utility = new FakeUtility()
|
||||||
|
const onExtensionStartupFailures = vi.fn(async () => undefined)
|
||||||
|
const launcher = createDeepSeekHarnessUtilityLauncher({
|
||||||
|
bundledHostPath: hostPath,
|
||||||
|
dshHome,
|
||||||
|
environment: {},
|
||||||
|
fork: () => utility as never,
|
||||||
|
onExtensionStartupFailures
|
||||||
|
})
|
||||||
|
|
||||||
|
const launching = launcher({
|
||||||
|
...launchOptions,
|
||||||
|
extensionPackages: [
|
||||||
|
{
|
||||||
|
id: 'greet',
|
||||||
|
entrypoint,
|
||||||
|
configuration: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(utility.messages).toHaveLength(1)
|
||||||
|
)
|
||||||
|
utility.emit('message', {
|
||||||
|
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||||
|
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||||
|
type: 'ready',
|
||||||
|
failedExtensionIds: ['greet']
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(launching).resolves.toBeDefined()
|
||||||
|
expect(onExtensionStartupFailures).toHaveBeenCalledWith([
|
||||||
|
'greet'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails when the Host exits while startup failures are being persisted', async () => {
|
||||||
|
const { dshHome, hostPath, launchOptions } = await fixture()
|
||||||
|
const utility = new FakeUtility()
|
||||||
|
let finishPersistence!: () => void
|
||||||
|
const persistence = new Promise<void>((resolve) => {
|
||||||
|
finishPersistence = resolve
|
||||||
|
})
|
||||||
|
const onExtensionStartupFailures = vi.fn(() => persistence)
|
||||||
|
const terminateProcess = vi.fn()
|
||||||
|
const launcher = createDeepSeekHarnessUtilityLauncher({
|
||||||
|
bundledHostPath: hostPath,
|
||||||
|
dshHome,
|
||||||
|
environment: {},
|
||||||
|
fork: () => utility as never,
|
||||||
|
terminateProcess,
|
||||||
|
onExtensionStartupFailures
|
||||||
|
})
|
||||||
|
|
||||||
|
const launching = launcher(launchOptions)
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(utility.messages).toHaveLength(1)
|
||||||
|
)
|
||||||
|
utility.emit('message', {
|
||||||
|
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||||
|
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||||
|
type: 'ready',
|
||||||
|
failedExtensionIds: ['greet']
|
||||||
|
})
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(onExtensionStartupFailures).toHaveBeenCalledOnce()
|
||||||
|
)
|
||||||
|
utility.emit('exit', 9)
|
||||||
|
|
||||||
|
await expect(launching).rejects.toThrow(
|
||||||
|
'Host 启动前退出(code 9)'
|
||||||
|
)
|
||||||
|
expect(terminateProcess).toHaveBeenCalledOnce()
|
||||||
|
finishPersistence()
|
||||||
|
})
|
||||||
|
|
||||||
it('fails closed on an invalid Host startup message', async () => {
|
it('fails closed on an invalid Host startup message', async () => {
|
||||||
const { dshHome, hostPath, launchOptions } = await fixture()
|
const { dshHome, hostPath, launchOptions } = await fixture()
|
||||||
const utility = new FakeUtility()
|
const utility = new FakeUtility()
|
||||||
|
|||||||
@@ -2,121 +2,34 @@ import { Readable } from 'node:stream'
|
|||||||
import { realpath, stat } from 'node:fs/promises'
|
import { realpath, stat } from 'node:fs/promises'
|
||||||
import { isAbsolute } from 'node:path'
|
import { isAbsolute } from 'node:path'
|
||||||
import type { UtilityProcess } from 'electron'
|
import type { UtilityProcess } from 'electron'
|
||||||
import { z } from 'zod'
|
|
||||||
import { isDeepSeekHarnessCompatibleBaseUrl } from '../../shared/deepseek-harness-compatibility'
|
import { isDeepSeekHarnessCompatibleBaseUrl } from '../../shared/deepseek-harness-compatibility'
|
||||||
import type {
|
import type {
|
||||||
DeepSeekHarnessChild,
|
DeepSeekHarnessChild,
|
||||||
DeepSeekHarnessLaunchOptions
|
DeepSeekHarnessLaunchOptions
|
||||||
} from './deepseek-harness-runtime'
|
} from './deepseek-harness-runtime'
|
||||||
import { createDeepSeekHarnessUtilityChild } from './deepseek-harness-utility-transport'
|
import { createDeepSeekHarnessUtilityChild } from './deepseek-harness-utility-transport'
|
||||||
|
import {
|
||||||
|
controlledHarnessHostConfigSchema,
|
||||||
|
DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||||
|
DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||||
|
DEEPSEEK_HARNESS_CREDENTIAL_REF,
|
||||||
|
DEEPSEEK_HARNESS_HOST_VERSION,
|
||||||
|
parseHarnessControlMessage,
|
||||||
|
type DeepSeekHarnessControlMessage as HarnessControlMessage
|
||||||
|
} from './deepseek-harness-control-protocol'
|
||||||
|
|
||||||
export const DEEPSEEK_HARNESS_CONTROL_PROTOCOL =
|
export {
|
||||||
'goodbuddy.deepseek-harness.control'
|
controlledHarnessHostConfigSchema,
|
||||||
export const DEEPSEEK_HARNESS_CONTROL_VERSION = 1
|
DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||||
export const DEEPSEEK_HARNESS_HOST_VERSION = '0.1.0-rc.6'
|
DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||||
export const DEEPSEEK_HARNESS_CREDENTIAL_REF =
|
DEEPSEEK_HARNESS_CREDENTIAL_REF,
|
||||||
'GOODBUDDY_HARNESS_MODEL_API_KEY'
|
DEEPSEEK_HARNESS_HOST_VERSION,
|
||||||
|
parseHarnessControlMessage
|
||||||
const skillPackageSchema = z
|
} from './deepseek-harness-control-protocol'
|
||||||
.object({
|
export type {
|
||||||
id: z
|
ControlledHarnessBootstrapConfig,
|
||||||
.string()
|
DeepSeekHarnessControlMessage
|
||||||
.min(1)
|
} from './deepseek-harness-control-protocol'
|
||||||
.max(128)
|
|
||||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u),
|
|
||||||
directory: z.string().min(1).max(32_768).refine(isAbsolute)
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
|
|
||||||
export const controlledHarnessHostConfigSchema = z
|
|
||||||
.object({
|
|
||||||
workspace: z.string().min(1).max(32_768).refine(isAbsolute),
|
|
||||||
dshHome: z.string().min(1).max(32_768).refine(isAbsolute),
|
|
||||||
baseUrl: z
|
|
||||||
.url()
|
|
||||||
.max(2_048)
|
|
||||||
.refine(isDeepSeekHarnessCompatibleBaseUrl),
|
|
||||||
api: z.literal('openai-completions'),
|
|
||||||
provider: z.literal('goodbuddy'),
|
|
||||||
model: z.string().min(1).max(128),
|
|
||||||
harnessVersion: z.literal(DEEPSEEK_HARNESS_HOST_VERSION),
|
|
||||||
credentialRefs: z
|
|
||||||
.tuple([z.literal(DEEPSEEK_HARNESS_CREDENTIAL_REF)])
|
|
||||||
.readonly(),
|
|
||||||
skillPackages: z.array(skillPackageSchema).max(64),
|
|
||||||
maxFrameBytes: z.literal(1024 * 1024)
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
|
|
||||||
export type ControlledHarnessBootstrapConfig = z.infer<
|
|
||||||
typeof controlledHarnessHostConfigSchema
|
|
||||||
>
|
|
||||||
|
|
||||||
export type DeepSeekHarnessControlMessage =
|
|
||||||
| {
|
|
||||||
protocol: typeof DEEPSEEK_HARNESS_CONTROL_PROTOCOL
|
|
||||||
version: typeof DEEPSEEK_HARNESS_CONTROL_VERSION
|
|
||||||
type: 'start'
|
|
||||||
config: ControlledHarnessBootstrapConfig
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
protocol: typeof DEEPSEEK_HARNESS_CONTROL_PROTOCOL
|
|
||||||
version: typeof DEEPSEEK_HARNESS_CONTROL_VERSION
|
|
||||||
type: 'ready'
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
protocol: typeof DEEPSEEK_HARNESS_CONTROL_PROTOCOL
|
|
||||||
version: typeof DEEPSEEK_HARNESS_CONTROL_VERSION
|
|
||||||
type: 'fatal'
|
|
||||||
code: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseHarnessControlMessage(
|
|
||||||
value: unknown
|
|
||||||
): DeepSeekHarnessControlMessage | undefined {
|
|
||||||
if (
|
|
||||||
!value ||
|
|
||||||
typeof value !== 'object' ||
|
|
||||||
Array.isArray(value)
|
|
||||||
) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
const record = value as Record<string, unknown>
|
|
||||||
if (
|
|
||||||
record.protocol !== DEEPSEEK_HARNESS_CONTROL_PROTOCOL ||
|
|
||||||
record.version !== DEEPSEEK_HARNESS_CONTROL_VERSION
|
|
||||||
) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
if (record.type === 'ready' && Object.keys(record).length === 3) {
|
|
||||||
return record as DeepSeekHarnessControlMessage
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
record.type === 'fatal' &&
|
|
||||||
Object.keys(record).length === 4 &&
|
|
||||||
typeof record.code === 'string' &&
|
|
||||||
/^[A-Z][A-Z0-9_]{0,63}$/u.test(record.code)
|
|
||||||
) {
|
|
||||||
return record as DeepSeekHarnessControlMessage
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
record.type === 'start' &&
|
|
||||||
Object.keys(record).length === 4
|
|
||||||
) {
|
|
||||||
const parsed = controlledHarnessHostConfigSchema.safeParse(
|
|
||||||
record.config
|
|
||||||
)
|
|
||||||
return parsed.success
|
|
||||||
? ({
|
|
||||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
|
||||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
|
||||||
type: 'start',
|
|
||||||
config: parsed.data
|
|
||||||
} satisfies DeepSeekHarnessControlMessage)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DeepSeekHarnessFork = (
|
export type DeepSeekHarnessFork = (
|
||||||
modulePath: string,
|
modulePath: string,
|
||||||
@@ -135,6 +48,9 @@ export type DeepSeekHarnessUtilityLauncherOptions = {
|
|||||||
environment: NodeJS.ProcessEnv
|
environment: NodeJS.ProcessEnv
|
||||||
fork: DeepSeekHarnessFork
|
fork: DeepSeekHarnessFork
|
||||||
terminateProcess?: (utility: UtilityProcess) => void
|
terminateProcess?: (utility: UtilityProcess) => void
|
||||||
|
onExtensionStartupFailures?: (
|
||||||
|
extensionIds: readonly string[]
|
||||||
|
) => Promise<void>
|
||||||
startupTimeoutMs?: number
|
startupTimeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +103,21 @@ export function createDeepSeekHarnessUtilityLauncher(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
const canonicalExtensionPackages = await Promise.all(
|
||||||
|
options.extensionPackages.map(async (extension) => {
|
||||||
|
const entrypoint = await realpath(extension.entrypoint)
|
||||||
|
const metadata = await stat(entrypoint)
|
||||||
|
if (!metadata.isFile()) {
|
||||||
|
throw new Error(
|
||||||
|
'DeepSeek Harness 插件入口必须为文件'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...extension,
|
||||||
|
entrypoint
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
const [canonicalHostPath, canonicalWorkspace, canonicalDshHome] =
|
const [canonicalHostPath, canonicalWorkspace, canonicalDshHome] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
realpath(hostPath),
|
realpath(hostPath),
|
||||||
@@ -240,11 +171,16 @@ export function createDeepSeekHarnessUtilityLauncher(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const startupTimeoutMs =
|
const startupTimeoutMs =
|
||||||
launcherOptions.startupTimeoutMs ?? 10_000
|
launcherOptions.startupTimeoutMs ??
|
||||||
|
Math.min(
|
||||||
|
120_000,
|
||||||
|
10_000 + canonicalExtensionPackages.length * 5_000
|
||||||
|
)
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
let onAbort: (() => void) | undefined
|
let onAbort: (() => void) | undefined
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let settled = false
|
||||||
const cleanup = (): void => {
|
const cleanup = (): void => {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
@@ -256,10 +192,22 @@ export function createDeepSeekHarnessUtilityLauncher(
|
|||||||
utility.removeListener('exit', onExit)
|
utility.removeListener('exit', onExit)
|
||||||
}
|
}
|
||||||
const fail = (error: Error): void => {
|
const fail = (error: Error): void => {
|
||||||
|
if (settled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settled = true
|
||||||
cleanup()
|
cleanup()
|
||||||
terminate()
|
terminate()
|
||||||
reject(error)
|
reject(error)
|
||||||
}
|
}
|
||||||
|
const succeed = (): void => {
|
||||||
|
if (settled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settled = true
|
||||||
|
cleanup()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
const onMessage = (message: unknown): void => {
|
const onMessage = (message: unknown): void => {
|
||||||
const control = parseHarnessControlMessage(message)
|
const control = parseHarnessControlMessage(message)
|
||||||
if (!control) {
|
if (!control) {
|
||||||
@@ -267,8 +215,26 @@ export function createDeepSeekHarnessUtilityLauncher(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (control.type === 'ready') {
|
if (control.type === 'ready') {
|
||||||
cleanup()
|
utility.removeListener('message', onMessage)
|
||||||
resolve()
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
}
|
||||||
|
void (
|
||||||
|
control.failedExtensionIds.length > 0
|
||||||
|
? launcherOptions.onExtensionStartupFailures?.(
|
||||||
|
control.failedExtensionIds
|
||||||
|
) ?? Promise.resolve()
|
||||||
|
: Promise.resolve()
|
||||||
|
).then(succeed, (error: unknown) => {
|
||||||
|
fail(
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(
|
||||||
|
'DeepSeek Harness 插件失败状态保存失败'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
} else if (control.type === 'fatal') {
|
} else if (control.type === 'fatal') {
|
||||||
fail(
|
fail(
|
||||||
new Error(
|
new Error(
|
||||||
@@ -311,6 +277,7 @@ export function createDeepSeekHarnessUtilityLauncher(
|
|||||||
harnessVersion: DEEPSEEK_HARNESS_HOST_VERSION,
|
harnessVersion: DEEPSEEK_HARNESS_HOST_VERSION,
|
||||||
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
|
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
|
||||||
skillPackages: canonicalSkillPackages,
|
skillPackages: canonicalSkillPackages,
|
||||||
|
extensionPackages: canonicalExtensionPackages,
|
||||||
maxFrameBytes: 1024 * 1024
|
maxFrameBytes: 1024 * 1024
|
||||||
})
|
})
|
||||||
utility.postMessage({
|
utility.postMessage({
|
||||||
@@ -318,7 +285,7 @@ export function createDeepSeekHarnessUtilityLauncher(
|
|||||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||||
type: 'start',
|
type: 'start',
|
||||||
config
|
config
|
||||||
} satisfies DeepSeekHarnessControlMessage)
|
} satisfies HarnessControlMessage)
|
||||||
})
|
})
|
||||||
return createDeepSeekHarnessUtilityChild(utility, {
|
return createDeepSeekHarnessUtilityChild(utility, {
|
||||||
stderrToWeb: (stderr) =>
|
stderrToWeb: (stderr) =>
|
||||||
|
|||||||
@@ -127,7 +127,8 @@ describe('DeepSeek Harness utility byte transport', () => {
|
|||||||
utility.emitMessage({
|
utility.emitMessage({
|
||||||
protocol: 'goodbuddy.deepseek-harness.control',
|
protocol: 'goodbuddy.deepseek-harness.control',
|
||||||
version: 1,
|
version: 1,
|
||||||
type: 'ready'
|
type: 'ready',
|
||||||
|
failedExtensionIds: []
|
||||||
})
|
})
|
||||||
|
|
||||||
const reader = child.stdout.getReader()
|
const reader = child.stdout.getReader()
|
||||||
@@ -155,6 +156,7 @@ describe('DeepSeek Harness utility byte transport', () => {
|
|||||||
protocol: 'goodbuddy.deepseek-harness.control',
|
protocol: 'goodbuddy.deepseek-harness.control',
|
||||||
version: 1,
|
version: 1,
|
||||||
type: 'ready',
|
type: 'ready',
|
||||||
|
failedExtensionIds: [],
|
||||||
unexpected: true
|
unexpected: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { DeepSeekHarnessChild } from './deepseek-harness-runtime'
|
import type { DeepSeekHarnessChild } from './deepseek-harness-runtime'
|
||||||
|
import { parseHarnessControlMessage } from './deepseek-harness-control-protocol'
|
||||||
|
|
||||||
export const DEEPSEEK_HARNESS_BYTE_PROTOCOL =
|
export const DEEPSEEK_HARNESS_BYTE_PROTOCOL =
|
||||||
'goodbuddy.deepseek-harness.byte-stream'
|
'goodbuddy.deepseek-harness.byte-stream'
|
||||||
@@ -70,7 +71,6 @@ type EndpointOptions = {
|
|||||||
readonly onFailure?: () => void
|
readonly onFailure?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONTROL_PROTOCOL = 'goodbuddy.deepseek-harness.control'
|
|
||||||
const PROTOCOL_KEYS = ['protocol', 'version', 'type'] as const
|
const PROTOCOL_KEYS = ['protocol', 'version', 'type'] as const
|
||||||
const STREAM_KEYS = [...PROTOCOL_KEYS, 'stream', 'seq'] as const
|
const STREAM_KEYS = [...PROTOCOL_KEYS, 'stream', 'seq'] as const
|
||||||
const DATA_KEYS = [...STREAM_KEYS, 'bytes'] as const
|
const DATA_KEYS = [...STREAM_KEYS, 'bytes'] as const
|
||||||
@@ -167,29 +167,7 @@ function parseMessage(value: unknown): ProtocolMessage | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isControlMessage(value: unknown): boolean {
|
function isControlMessage(value: unknown): boolean {
|
||||||
if (
|
return parseHarnessControlMessage(value) !== undefined
|
||||||
!isRecord(value) ||
|
|
||||||
value.protocol !== CONTROL_PROTOCOL ||
|
|
||||||
value.version !== 1 ||
|
|
||||||
typeof value.type !== 'string'
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (value.type === 'ready') {
|
|
||||||
return hasExactKeys(value, PROTOCOL_KEYS)
|
|
||||||
}
|
|
||||||
if (value.type === 'fatal') {
|
|
||||||
return (
|
|
||||||
hasExactKeys(value, [...PROTOCOL_KEYS, 'code']) &&
|
|
||||||
typeof value.code === 'string' &&
|
|
||||||
/^[A-Z][A-Z0-9_]{0,63}$/u.test(value.code)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
value.type === 'start' &&
|
|
||||||
hasExactKeys(value, [...PROTOCOL_KEYS, 'config']) &&
|
|
||||||
isRecord(value.config)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ByteTransportEndpoint {
|
class ByteTransportEndpoint {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { startControlledDeepSeekHarnessHost } from '../deepseek-harness-host'
|
||||||
|
import {
|
||||||
|
DshNpmExtensionInstaller,
|
||||||
|
DshNpmMarketplaceCatalog
|
||||||
|
} from './dsh-extension-marketplace'
|
||||||
|
import { RuntimeExtensionStore } from './runtime-extension-store'
|
||||||
|
|
||||||
|
const enabled =
|
||||||
|
process.env.GOODBUDDY_DSH_MARKETPLACE_E2E === '1'
|
||||||
|
|
||||||
|
describe.skipIf(!enabled)('DSH marketplace live E2E', () => {
|
||||||
|
it(
|
||||||
|
'searches, installs, enables, loads, and calls a real npm plugin',
|
||||||
|
async () => {
|
||||||
|
const userDataPath = await mkdtemp(
|
||||||
|
join(tmpdir(), 'goodbuddy-dsh-marketplace-live-')
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const market = new DshNpmMarketplaceCatalog()
|
||||||
|
const greet = (await market.list()).find(
|
||||||
|
(entry) => entry.package.name === 'dsh-plugin-greet'
|
||||||
|
)
|
||||||
|
expect(greet).toBeDefined()
|
||||||
|
const npmCliPath = process.env.GOODBUDDY_DSH_NPM_CLI
|
||||||
|
? resolve(process.env.GOODBUDDY_DSH_NPM_CLI)
|
||||||
|
: resolve(
|
||||||
|
'node_modules',
|
||||||
|
'npm',
|
||||||
|
'bin',
|
||||||
|
'npm-cli.js'
|
||||||
|
)
|
||||||
|
const nodeExecutablePath =
|
||||||
|
process.env.GOODBUDDY_DSH_NODE_EXECUTABLE
|
||||||
|
? resolve(
|
||||||
|
process.env.GOODBUDDY_DSH_NODE_EXECUTABLE
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
const installer = new DshNpmExtensionInstaller({
|
||||||
|
dshHome: userDataPath,
|
||||||
|
npmCliPath,
|
||||||
|
...(nodeExecutablePath ? { nodeExecutablePath } : {})
|
||||||
|
})
|
||||||
|
const store = new RuntimeExtensionStore(userDataPath, {
|
||||||
|
catalog: {
|
||||||
|
list: async () => [greet!]
|
||||||
|
},
|
||||||
|
install: (input) => installer.install(input)
|
||||||
|
})
|
||||||
|
await store.apply({
|
||||||
|
type: 'set-marketplace-enabled',
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
const installed = await store.apply({
|
||||||
|
type: 'install',
|
||||||
|
extensionId: greet!.id,
|
||||||
|
package: greet!.package
|
||||||
|
})
|
||||||
|
expect(installed.installed).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: greet!.id,
|
||||||
|
package: greet!.package,
|
||||||
|
enabled: true,
|
||||||
|
integrity: expect.stringMatching(/^sha512-/u)
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
const extensions = await store.getEnabledExtensions()
|
||||||
|
const inbound = new TransformStream<
|
||||||
|
Record<string, unknown>,
|
||||||
|
Record<string, unknown>
|
||||||
|
>()
|
||||||
|
const outbound = new TransformStream<
|
||||||
|
Record<string, unknown>,
|
||||||
|
Record<string, unknown>
|
||||||
|
>()
|
||||||
|
const host = await startControlledDeepSeekHarnessHost({
|
||||||
|
workspace: userDataPath,
|
||||||
|
dshHome: userDataPath,
|
||||||
|
baseUrl: 'https://api.deepseek.com',
|
||||||
|
api: 'openai-completions',
|
||||||
|
provider: 'goodbuddy',
|
||||||
|
model: 'deepseek-test',
|
||||||
|
harnessVersion: '0.1.0-rc.6',
|
||||||
|
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||||
|
skillPackages: [],
|
||||||
|
extensionPackages: extensions,
|
||||||
|
stream: {
|
||||||
|
readable: inbound.readable,
|
||||||
|
writable: outbound.writable
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
expect(host.extensionFailures).toEqual([])
|
||||||
|
await expect(
|
||||||
|
host.context.tools.execute({
|
||||||
|
callId: 'marketplace-live-greet',
|
||||||
|
name: 'greet',
|
||||||
|
arguments: { name: 'GoodBuddy' },
|
||||||
|
signal: new AbortController().signal
|
||||||
|
} as never)
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
isError: false,
|
||||||
|
value: 'Hello, GoodBuddy!'
|
||||||
|
})
|
||||||
|
await host.dispose()
|
||||||
|
} finally {
|
||||||
|
await rm(userDataPath, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
maxRetries: 5,
|
||||||
|
retryDelay: 100
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
120_000
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import {
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
rm,
|
||||||
|
stat,
|
||||||
|
writeFile
|
||||||
|
} from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
DshNpmExtensionInstaller,
|
||||||
|
DshNpmMarketplaceCatalog,
|
||||||
|
type PackageManagerRunner
|
||||||
|
} from './dsh-extension-marketplace'
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = []
|
||||||
|
|
||||||
|
function response(value: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(value), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories.splice(0).map((directory) =>
|
||||||
|
rm(directory, { recursive: true, force: true })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DSH npm marketplace', () => {
|
||||||
|
it('loads every npm search page and keeps only DSH plugin packages', async () => {
|
||||||
|
const fetcher = vi.fn<typeof fetch>(async (input) => {
|
||||||
|
const from = Number(new URL(String(input)).searchParams.get('from'))
|
||||||
|
return response({
|
||||||
|
total: 251,
|
||||||
|
objects:
|
||||||
|
from === 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
package: {
|
||||||
|
name: 'dsh-plugin-greet',
|
||||||
|
version: '0.1.0',
|
||||||
|
description: 'A greeting tool.',
|
||||||
|
keywords: ['dsh-plugin'],
|
||||||
|
license: 'MIT',
|
||||||
|
links: {
|
||||||
|
repository:
|
||||||
|
'git+https://github.com/example/greet.git'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
package: {
|
||||||
|
name: 'not-a-plugin',
|
||||||
|
version: '1.0.0',
|
||||||
|
keywords: ['unrelated']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
package: {
|
||||||
|
name: 'dsh-second-plugin',
|
||||||
|
version: '2.0.0',
|
||||||
|
keywords: ['dsh-plugin']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const catalog = new DshNpmMarketplaceCatalog({
|
||||||
|
fetcher,
|
||||||
|
cacheTtlMs: 60_000
|
||||||
|
})
|
||||||
|
|
||||||
|
const entries = await catalog.list()
|
||||||
|
|
||||||
|
expect(entries.map((entry) => entry.package.name)).toEqual([
|
||||||
|
'dsh-plugin-greet',
|
||||||
|
'dsh-second-plugin'
|
||||||
|
])
|
||||||
|
expect(entries[0]).toMatchObject({
|
||||||
|
description: 'A greeting tool.',
|
||||||
|
repository: 'https://github.com/example/greet.git'
|
||||||
|
})
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||||
|
await catalog.list()
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coalesces concurrent catalog loads', async () => {
|
||||||
|
let resolveFetch: ((response: Response) => void) | undefined
|
||||||
|
const fetcher = vi.fn<typeof fetch>(
|
||||||
|
() =>
|
||||||
|
new Promise<Response>((resolve) => {
|
||||||
|
resolveFetch = resolve
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const catalog = new DshNpmMarketplaceCatalog({ fetcher })
|
||||||
|
|
||||||
|
const first = catalog.list()
|
||||||
|
const second = catalog.list()
|
||||||
|
expect(fetcher).toHaveBeenCalledOnce()
|
||||||
|
resolveFetch?.(
|
||||||
|
response({
|
||||||
|
total: 1,
|
||||||
|
objects: [
|
||||||
|
{
|
||||||
|
package: {
|
||||||
|
name: 'dsh-plugin-greet',
|
||||||
|
version: '0.1.0',
|
||||||
|
keywords: ['dsh-plugin']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||||
|
[
|
||||||
|
expect.objectContaining({
|
||||||
|
package: expect.objectContaining({
|
||||||
|
name: 'dsh-plugin-greet'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
],
|
||||||
|
[
|
||||||
|
expect.objectContaining({
|
||||||
|
package: expect.objectContaining({
|
||||||
|
name: 'dsh-plugin-greet'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
]
|
||||||
|
])
|
||||||
|
expect(fetcher).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses bundled npm to install the exact package and verifies its entrypoint', async () => {
|
||||||
|
const destinationDirectory = await mkdtemp(
|
||||||
|
join(tmpdir(), 'goodbuddy-dsh-npm-installer-')
|
||||||
|
)
|
||||||
|
temporaryDirectories.push(destinationDirectory)
|
||||||
|
const integrity = `sha512-${Buffer.from('verified').toString(
|
||||||
|
'base64'
|
||||||
|
)}`
|
||||||
|
const packageName = 'dsh-plugin-greet'
|
||||||
|
const version = '0.1.0'
|
||||||
|
const manifest = {
|
||||||
|
name: packageName,
|
||||||
|
version,
|
||||||
|
main: 'index.js',
|
||||||
|
dist: { integrity },
|
||||||
|
dsh: { bundle: { patch: './cordis.patch.yml' } }
|
||||||
|
}
|
||||||
|
const npmCliPath = join(destinationDirectory, 'npm-cli.js')
|
||||||
|
await writeFile(npmCliPath, '// bundled npm fixture\n', 'utf8')
|
||||||
|
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||||
|
response({
|
||||||
|
versions: {
|
||||||
|
[version]: manifest
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const runner: PackageManagerRunner = vi.fn(
|
||||||
|
async (_command, _args, options) => {
|
||||||
|
const installedDirectory = join(
|
||||||
|
options.cwd,
|
||||||
|
'node_modules',
|
||||||
|
packageName
|
||||||
|
)
|
||||||
|
await mkdir(installedDirectory, { recursive: true })
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(
|
||||||
|
join(installedDirectory, 'package.json'),
|
||||||
|
JSON.stringify(manifest),
|
||||||
|
'utf8'
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
join(installedDirectory, 'index.js'),
|
||||||
|
'export function apply() {}\n',
|
||||||
|
'utf8'
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
join(options.cwd, 'package-lock.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
packages: {
|
||||||
|
[`node_modules/${packageName}`]: { integrity }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
])
|
||||||
|
return { exitCode: 0, stdout: '', stderr: '' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const installer = new DshNpmExtensionInstaller({
|
||||||
|
dshHome: destinationDirectory,
|
||||||
|
npmCliPath,
|
||||||
|
fetcher,
|
||||||
|
runner,
|
||||||
|
environment: { PATH: 'C:\\Node' }
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
installer.install({
|
||||||
|
entry: {
|
||||||
|
id: 'greet',
|
||||||
|
package: { name: packageName, version },
|
||||||
|
displayName: packageName,
|
||||||
|
description: 'A greeting tool.'
|
||||||
|
},
|
||||||
|
destinationDirectory
|
||||||
|
})
|
||||||
|
).resolves.toEqual({
|
||||||
|
entrypoint: `node_modules/${packageName}/index.js`,
|
||||||
|
integrity
|
||||||
|
})
|
||||||
|
expect(runner).toHaveBeenCalledWith(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
npmCliPath,
|
||||||
|
'install',
|
||||||
|
'--save-exact',
|
||||||
|
'--no-audit',
|
||||||
|
'--no-fund',
|
||||||
|
'--dangerously-allow-all-scripts',
|
||||||
|
'--loglevel=error',
|
||||||
|
`${packageName}@${version}`
|
||||||
|
],
|
||||||
|
expect.objectContaining({
|
||||||
|
cwd: destinationDirectory,
|
||||||
|
env: expect.objectContaining({
|
||||||
|
ELECTRON_RUN_AS_NODE: '1',
|
||||||
|
npm_execpath: npmCliPath,
|
||||||
|
npm_node_execpath: process.execPath
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await stat(
|
||||||
|
join(
|
||||||
|
destinationDirectory,
|
||||||
|
'package-manager-bin',
|
||||||
|
process.platform === 'win32' ? 'node.cmd' : 'node'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).isFile()
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects packages that do not declare a DSH bundle', async () => {
|
||||||
|
const directory = await mkdtemp(
|
||||||
|
join(tmpdir(), 'goodbuddy-dsh-not-plugin-')
|
||||||
|
)
|
||||||
|
temporaryDirectories.push(directory)
|
||||||
|
const installer = new DshNpmExtensionInstaller({
|
||||||
|
dshHome: directory,
|
||||||
|
fetcher: vi.fn<typeof fetch>(async () =>
|
||||||
|
response({
|
||||||
|
versions: {
|
||||||
|
'1.0.0': {
|
||||||
|
name: 'not-a-dsh-plugin',
|
||||||
|
version: '1.0.0',
|
||||||
|
main: 'index.js',
|
||||||
|
dist: {
|
||||||
|
integrity: `sha512-${Buffer.from('verified').toString(
|
||||||
|
'base64'
|
||||||
|
)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
),
|
||||||
|
runner: vi.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
installer.install({
|
||||||
|
entry: {
|
||||||
|
id: 'not-plugin',
|
||||||
|
package: {
|
||||||
|
name: 'not-a-dsh-plugin',
|
||||||
|
version: '1.0.0'
|
||||||
|
},
|
||||||
|
displayName: 'Not a plugin',
|
||||||
|
description: 'Missing DSH bundle metadata.'
|
||||||
|
},
|
||||||
|
destinationDirectory: directory
|
||||||
|
})
|
||||||
|
).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,717 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import {
|
||||||
|
chmod,
|
||||||
|
mkdir,
|
||||||
|
readFile,
|
||||||
|
stat,
|
||||||
|
writeFile
|
||||||
|
} from 'node:fs/promises'
|
||||||
|
import {
|
||||||
|
delimiter,
|
||||||
|
join,
|
||||||
|
posix,
|
||||||
|
relative
|
||||||
|
} from 'node:path'
|
||||||
|
import spawn from 'cross-spawn'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import {
|
||||||
|
runtimeExtensionCatalogEntrySchema,
|
||||||
|
runtimeExtensionIntegritySchema,
|
||||||
|
runtimeExtensionPackageNameSchema,
|
||||||
|
runtimeExtensionVersionSchema,
|
||||||
|
type RuntimeExtensionCatalogEntry
|
||||||
|
} from '../../shared/runtime-extension-contracts'
|
||||||
|
import { buildControlledHarnessEnvironment } from './process-environment'
|
||||||
|
import type {
|
||||||
|
RuntimeExtensionCatalog,
|
||||||
|
RuntimeExtensionStoreDependencies
|
||||||
|
} from './runtime-extension-store'
|
||||||
|
|
||||||
|
const NPM_REGISTRY_URL = 'https://registry.npmjs.org'
|
||||||
|
const NPM_SEARCH_PAGE_SIZE = 250
|
||||||
|
const MAXIMUM_CATALOG_ENTRIES = 1_000
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000
|
||||||
|
const DEFAULT_INSTALL_TIMEOUT_MS = 5 * 60_000
|
||||||
|
const MAXIMUM_PROCESS_OUTPUT_CHARACTERS = 64 * 1024
|
||||||
|
|
||||||
|
const npmSearchPackageSchema = z
|
||||||
|
.object({
|
||||||
|
name: runtimeExtensionPackageNameSchema,
|
||||||
|
version: runtimeExtensionVersionSchema,
|
||||||
|
description: z.string().optional(),
|
||||||
|
keywords: z.array(z.string()).optional(),
|
||||||
|
license: z.string().optional(),
|
||||||
|
links: z
|
||||||
|
.object({
|
||||||
|
homepage: z.string().optional(),
|
||||||
|
repository: z.string().optional(),
|
||||||
|
npm: z.string().optional()
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
|
const npmSearchResponseSchema = z
|
||||||
|
.object({
|
||||||
|
total: z.number().int().nonnegative(),
|
||||||
|
objects: z.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
package: npmSearchPackageSchema
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
|
const npmDistributionSchema = z
|
||||||
|
.object({
|
||||||
|
integrity: runtimeExtensionIntegritySchema
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
|
const npmInstalledManifestSchema = z
|
||||||
|
.object({
|
||||||
|
name: runtimeExtensionPackageNameSchema,
|
||||||
|
version: runtimeExtensionVersionSchema,
|
||||||
|
main: z.string().optional(),
|
||||||
|
exports: z.unknown().optional(),
|
||||||
|
dsh: z
|
||||||
|
.object({
|
||||||
|
bundle: z
|
||||||
|
.object({
|
||||||
|
patch: z.string().min(1)
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
|
const npmVersionManifestSchema = npmInstalledManifestSchema.extend({
|
||||||
|
dist: npmDistributionSchema
|
||||||
|
})
|
||||||
|
|
||||||
|
const npmPackumentSchema = z
|
||||||
|
.object({
|
||||||
|
versions: z.record(z.string(), npmVersionManifestSchema)
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
|
type NpmVersionManifest = z.infer<typeof npmVersionManifestSchema>
|
||||||
|
|
||||||
|
export type PackageManagerRunResult = {
|
||||||
|
exitCode: number
|
||||||
|
stdout: string
|
||||||
|
stderr: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PackageManagerRunner = (
|
||||||
|
command: string,
|
||||||
|
args: readonly string[],
|
||||||
|
options: {
|
||||||
|
cwd: string
|
||||||
|
env: NodeJS.ProcessEnv
|
||||||
|
timeoutMs: number
|
||||||
|
}
|
||||||
|
) => Promise<PackageManagerRunResult>
|
||||||
|
|
||||||
|
function waitForProcessClose(
|
||||||
|
child: ReturnType<typeof spawn>
|
||||||
|
): Promise<void> {
|
||||||
|
if (child.exitCode !== null) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(resolve, 5_000)
|
||||||
|
child.once('close', () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminatePackageManager(
|
||||||
|
child: ReturnType<typeof spawn>
|
||||||
|
): Promise<void> {
|
||||||
|
const closed = waitForProcessClose(child)
|
||||||
|
if (process.platform === 'win32' && child.pid) {
|
||||||
|
const killer = spawn(
|
||||||
|
'taskkill.exe',
|
||||||
|
['/PID', String(child.pid), '/T', '/F'],
|
||||||
|
{
|
||||||
|
shell: false,
|
||||||
|
stdio: 'ignore',
|
||||||
|
windowsHide: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timer = setTimeout(resolve, 5_000)
|
||||||
|
const finish = (): void => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
killer.once('close', finish)
|
||||||
|
killer.once('error', finish)
|
||||||
|
})
|
||||||
|
} else if (child.pid) {
|
||||||
|
try {
|
||||||
|
process.kill(-child.pid, 'SIGKILL')
|
||||||
|
} catch {
|
||||||
|
child.kill('SIGKILL')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
child.kill('SIGKILL')
|
||||||
|
}
|
||||||
|
if (child.exitCode === null) {
|
||||||
|
child.kill('SIGKILL')
|
||||||
|
}
|
||||||
|
await closed
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedAppend(current: string, chunk: unknown): string {
|
||||||
|
const next = current + String(chunk)
|
||||||
|
return next.length <= MAXIMUM_PROCESS_OUTPUT_CHARACTERS
|
||||||
|
? next
|
||||||
|
: next.slice(-MAXIMUM_PROCESS_OUTPUT_CHARACTERS)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runPackageManager: PackageManagerRunner = (
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
options
|
||||||
|
) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, [...args], {
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
detached: process.platform !== 'win32',
|
||||||
|
shell: false,
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
})
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
let settled = false
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settled = true
|
||||||
|
void terminatePackageManager(child).then(
|
||||||
|
() => reject(new Error('DSH 插件安装超时')),
|
||||||
|
() => reject(new Error('DSH 插件安装超时'))
|
||||||
|
)
|
||||||
|
}, options.timeoutMs)
|
||||||
|
child.stdout?.on('data', (chunk) => {
|
||||||
|
stdout = boundedAppend(stdout, chunk)
|
||||||
|
})
|
||||||
|
child.stderr?.on('data', (chunk) => {
|
||||||
|
stderr = boundedAppend(stderr, chunk)
|
||||||
|
})
|
||||||
|
child.once('error', (error) => {
|
||||||
|
if (settled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
child.once('close', (code) => {
|
||||||
|
if (settled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
resolve({
|
||||||
|
exitCode: code ?? 1,
|
||||||
|
stdout,
|
||||||
|
stderr
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function publicHttpUrl(value: string | undefined): string | undefined {
|
||||||
|
if (!value) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const normalized = value
|
||||||
|
.trim()
|
||||||
|
.replace(/^git\+/u, '')
|
||||||
|
.replace(/^git:\/\/github\.com\//u, 'https://github.com/')
|
||||||
|
.replace(/^git@github\.com:/u, 'https://github.com/')
|
||||||
|
try {
|
||||||
|
const url = new URL(normalized)
|
||||||
|
return url.protocol === 'https:' || url.protocol === 'http:'
|
||||||
|
? url.toString()
|
||||||
|
: undefined
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionId(packageName: string): string {
|
||||||
|
const slug = packageName
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^@/u, '')
|
||||||
|
.replace(/[^a-z0-9]+/gu, '-')
|
||||||
|
.replace(/^-+|-+$/gu, '')
|
||||||
|
.slice(0, 100)
|
||||||
|
const digest = createHash('sha256')
|
||||||
|
.update(packageName)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 12)
|
||||||
|
return `${slug || 'extension'}-${digest}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogEntry(
|
||||||
|
packageMetadata: z.infer<typeof npmSearchPackageSchema>
|
||||||
|
): RuntimeExtensionCatalogEntry {
|
||||||
|
const repository =
|
||||||
|
publicHttpUrl(packageMetadata.links?.repository) ??
|
||||||
|
publicHttpUrl(packageMetadata.links?.homepage) ??
|
||||||
|
publicHttpUrl(packageMetadata.links?.npm)
|
||||||
|
return runtimeExtensionCatalogEntrySchema.parse({
|
||||||
|
id: extensionId(packageMetadata.name),
|
||||||
|
package: {
|
||||||
|
name: packageMetadata.name,
|
||||||
|
version: packageMetadata.version
|
||||||
|
},
|
||||||
|
displayName: packageMetadata.name,
|
||||||
|
description:
|
||||||
|
packageMetadata.description?.trim().slice(0, 2_000) ||
|
||||||
|
`DeepSeek Harness plugin ${packageMetadata.name}`,
|
||||||
|
...(repository ? { repository } : {}),
|
||||||
|
...(packageMetadata.license?.trim()
|
||||||
|
? { license: packageMetadata.license.trim().slice(0, 128) }
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(
|
||||||
|
fetcher: typeof fetch,
|
||||||
|
url: URL,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<unknown> {
|
||||||
|
const response = await fetcher(url, {
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
'user-agent': 'GoodBuddy-DSH-Marketplace/1'
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(timeoutMs)
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`DSH 插件市场请求失败(HTTP ${response.status})`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DshNpmMarketplaceCatalog
|
||||||
|
implements RuntimeExtensionCatalog
|
||||||
|
{
|
||||||
|
private cache?: {
|
||||||
|
expiresAt: number
|
||||||
|
entries: RuntimeExtensionCatalogEntry[]
|
||||||
|
}
|
||||||
|
private inFlight?: Promise<
|
||||||
|
readonly RuntimeExtensionCatalogEntry[]
|
||||||
|
>
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly options: {
|
||||||
|
fetcher?: typeof fetch
|
||||||
|
registryUrl?: string
|
||||||
|
requestTimeoutMs?: number
|
||||||
|
cacheTtlMs?: number
|
||||||
|
} = {}
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(): Promise<readonly RuntimeExtensionCatalogEntry[]> {
|
||||||
|
const now = Date.now()
|
||||||
|
if (this.cache && this.cache.expiresAt > now) {
|
||||||
|
return this.cache.entries
|
||||||
|
}
|
||||||
|
if (this.inFlight) {
|
||||||
|
return this.inFlight
|
||||||
|
}
|
||||||
|
const request = this.load()
|
||||||
|
this.inFlight = request
|
||||||
|
try {
|
||||||
|
return await request
|
||||||
|
} finally {
|
||||||
|
if (this.inFlight === request) {
|
||||||
|
this.inFlight = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(): Promise<
|
||||||
|
readonly RuntimeExtensionCatalogEntry[]
|
||||||
|
> {
|
||||||
|
const fetcher = this.options.fetcher ?? fetch
|
||||||
|
const registryUrl = (
|
||||||
|
this.options.registryUrl ?? NPM_REGISTRY_URL
|
||||||
|
).replace(/\/$/u, '')
|
||||||
|
const timeoutMs =
|
||||||
|
this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
|
||||||
|
const first = await this.fetchPage(
|
||||||
|
fetcher,
|
||||||
|
registryUrl,
|
||||||
|
0,
|
||||||
|
timeoutMs
|
||||||
|
)
|
||||||
|
const total = Math.min(
|
||||||
|
first.total,
|
||||||
|
MAXIMUM_CATALOG_ENTRIES
|
||||||
|
)
|
||||||
|
const offsets: number[] = []
|
||||||
|
for (
|
||||||
|
let offset = NPM_SEARCH_PAGE_SIZE;
|
||||||
|
offset < total;
|
||||||
|
offset += NPM_SEARCH_PAGE_SIZE
|
||||||
|
) {
|
||||||
|
offsets.push(offset)
|
||||||
|
}
|
||||||
|
const remaining = await Promise.all(
|
||||||
|
offsets.map((offset) =>
|
||||||
|
this.fetchPage(fetcher, registryUrl, offset, timeoutMs)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const packages = [first, ...remaining].flatMap((page) =>
|
||||||
|
page.objects.map((item) => item.package)
|
||||||
|
)
|
||||||
|
const entries = [
|
||||||
|
...new Map(
|
||||||
|
packages
|
||||||
|
.filter((item) =>
|
||||||
|
item.keywords?.some(
|
||||||
|
(keyword) => keyword.toLowerCase() === 'dsh-plugin'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map((item) => [item.name, catalogEntry(item)] as const)
|
||||||
|
).values()
|
||||||
|
].sort((left, right) =>
|
||||||
|
left.displayName.localeCompare(right.displayName, 'en')
|
||||||
|
)
|
||||||
|
this.cache = {
|
||||||
|
expiresAt:
|
||||||
|
Date.now() +
|
||||||
|
(this.options.cacheTtlMs ?? 5 * 60_000),
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchPage(
|
||||||
|
fetcher: typeof fetch,
|
||||||
|
registryUrl: string,
|
||||||
|
from: number,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<z.infer<typeof npmSearchResponseSchema>> {
|
||||||
|
const url = new URL(`${registryUrl}/-/v1/search`)
|
||||||
|
url.searchParams.set('text', 'keywords:dsh-plugin')
|
||||||
|
url.searchParams.set('size', String(NPM_SEARCH_PAGE_SIZE))
|
||||||
|
url.searchParams.set('from', String(from))
|
||||||
|
return npmSearchResponseSchema.parse(
|
||||||
|
await fetchJson(fetcher, url, timeoutMs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageDirectory(
|
||||||
|
destinationDirectory: string,
|
||||||
|
packageName: string
|
||||||
|
): string {
|
||||||
|
return join(
|
||||||
|
destinationDirectory,
|
||||||
|
'node_modules',
|
||||||
|
...packageName.split('/')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportsEntrypoint(value: unknown): string | undefined {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
exportsEntrypoint(record['.']) ??
|
||||||
|
exportsEntrypoint(record.import) ??
|
||||||
|
exportsEntrypoint(record.default) ??
|
||||||
|
exportsEntrypoint(record.require)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntrypoint(
|
||||||
|
manifest: z.infer<typeof npmInstalledManifestSchema>
|
||||||
|
): string {
|
||||||
|
const entrypoint =
|
||||||
|
manifest.main ??
|
||||||
|
exportsEntrypoint(manifest.exports) ??
|
||||||
|
'index.js'
|
||||||
|
const normalized = posix.normalize(entrypoint.replaceAll('\\', '/'))
|
||||||
|
if (
|
||||||
|
!normalized ||
|
||||||
|
normalized === '.' ||
|
||||||
|
normalized === '..' ||
|
||||||
|
normalized.startsWith('../') ||
|
||||||
|
normalized.startsWith('/') ||
|
||||||
|
/^[A-Za-z]:/u.test(normalized)
|
||||||
|
) {
|
||||||
|
throw new Error('DSH 插件入口无效')
|
||||||
|
}
|
||||||
|
return normalized.replace(/^\.\//u, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageManagerError(error: unknown): Error {
|
||||||
|
const code =
|
||||||
|
error &&
|
||||||
|
typeof error === 'object' &&
|
||||||
|
'code' in error &&
|
||||||
|
typeof error.code === 'string'
|
||||||
|
? error.code
|
||||||
|
: undefined
|
||||||
|
return code === 'ENOENT'
|
||||||
|
? new Error('DSH 插件安装 Runtime 不可用')
|
||||||
|
: error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error('DSH 插件安装失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotePosixShell(value: string): string {
|
||||||
|
return `'${value.replaceAll("'", "'\\''")}'`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareNodeCommand(
|
||||||
|
directory: string,
|
||||||
|
executablePath: string
|
||||||
|
): Promise<string> {
|
||||||
|
await mkdir(directory, { recursive: true, mode: 0o700 })
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const commandPath = join(directory, 'node.cmd')
|
||||||
|
await writeFile(
|
||||||
|
commandPath,
|
||||||
|
[
|
||||||
|
'@echo off',
|
||||||
|
'set "ELECTRON_RUN_AS_NODE=1"',
|
||||||
|
`"${executablePath.replaceAll('%', '%%')}" %*`,
|
||||||
|
''
|
||||||
|
].join('\r\n'),
|
||||||
|
{ encoding: 'utf8', mode: 0o700 }
|
||||||
|
)
|
||||||
|
return commandPath
|
||||||
|
}
|
||||||
|
const commandPath = join(directory, 'node')
|
||||||
|
await writeFile(
|
||||||
|
commandPath,
|
||||||
|
[
|
||||||
|
'#!/bin/sh',
|
||||||
|
`ELECTRON_RUN_AS_NODE=1 exec ${quotePosixShell(executablePath)} "$@"`,
|
||||||
|
''
|
||||||
|
].join('\n'),
|
||||||
|
{ encoding: 'utf8', mode: 0o700 }
|
||||||
|
)
|
||||||
|
await chmod(commandPath, 0o700)
|
||||||
|
return commandPath
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DshNpmExtensionInstaller {
|
||||||
|
constructor(
|
||||||
|
private readonly options: {
|
||||||
|
dshHome: string
|
||||||
|
npmCliPath?: string
|
||||||
|
nodeExecutablePath?: string
|
||||||
|
fetcher?: typeof fetch
|
||||||
|
registryUrl?: string
|
||||||
|
runner?: PackageManagerRunner
|
||||||
|
requestTimeoutMs?: number
|
||||||
|
installTimeoutMs?: number
|
||||||
|
environment?: NodeJS.ProcessEnv
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async install(
|
||||||
|
input: Parameters<
|
||||||
|
RuntimeExtensionStoreDependencies['install']
|
||||||
|
>[0]
|
||||||
|
): Promise<{
|
||||||
|
entrypoint: string
|
||||||
|
integrity?: string
|
||||||
|
}> {
|
||||||
|
const manifest = await this.resolveManifest(input.entry)
|
||||||
|
await writeFile(
|
||||||
|
join(input.destinationDirectory, 'package.json'),
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
name: 'goodbuddy-dsh-extension-host',
|
||||||
|
private: true,
|
||||||
|
version: '1.0.0'
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`,
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const environment =
|
||||||
|
this.options.environment ??
|
||||||
|
buildControlledHarnessEnvironment(this.options.dshHome)
|
||||||
|
const runner = this.options.runner ?? runPackageManager
|
||||||
|
const npmCliPath = this.options.npmCliPath
|
||||||
|
const nodeExecutablePath =
|
||||||
|
this.options.nodeExecutablePath ?? process.execPath
|
||||||
|
let command = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||||
|
let prefixArgs: string[] = []
|
||||||
|
let packageManagerEnvironment = environment
|
||||||
|
if (npmCliPath) {
|
||||||
|
const npmCli = await stat(npmCliPath).catch(() => undefined)
|
||||||
|
if (!npmCli?.isFile()) {
|
||||||
|
throw new Error('GoodBuddy 内置 npm Runtime 缺失')
|
||||||
|
}
|
||||||
|
const runtimeBin = join(
|
||||||
|
this.options.dshHome,
|
||||||
|
'package-manager-bin'
|
||||||
|
)
|
||||||
|
await prepareNodeCommand(runtimeBin, nodeExecutablePath)
|
||||||
|
const inheritedPath =
|
||||||
|
environment.PATH ?? environment.Path ?? ''
|
||||||
|
const runtimePath = inheritedPath
|
||||||
|
? `${runtimeBin}${delimiter}${inheritedPath}`
|
||||||
|
: runtimeBin
|
||||||
|
command = nodeExecutablePath
|
||||||
|
prefixArgs = [npmCliPath]
|
||||||
|
packageManagerEnvironment = {
|
||||||
|
...environment,
|
||||||
|
PATH: runtimePath,
|
||||||
|
Path: runtimePath,
|
||||||
|
ELECTRON_RUN_AS_NODE: '1',
|
||||||
|
npm_execpath: npmCliPath,
|
||||||
|
npm_node_execpath: nodeExecutablePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let result: PackageManagerRunResult
|
||||||
|
try {
|
||||||
|
result = await runner(
|
||||||
|
command,
|
||||||
|
[
|
||||||
|
...prefixArgs,
|
||||||
|
'install',
|
||||||
|
'--save-exact',
|
||||||
|
'--no-audit',
|
||||||
|
'--no-fund',
|
||||||
|
'--dangerously-allow-all-scripts',
|
||||||
|
'--loglevel=error',
|
||||||
|
`${input.entry.package.name}@${input.entry.package.version}`
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: input.destinationDirectory,
|
||||||
|
env: {
|
||||||
|
...packageManagerEnvironment,
|
||||||
|
npm_config_audit: 'false',
|
||||||
|
npm_config_fund: 'false',
|
||||||
|
npm_config_progress: 'false',
|
||||||
|
npm_config_update_notifier: 'false',
|
||||||
|
npm_config_registry:
|
||||||
|
this.options.registryUrl ?? NPM_REGISTRY_URL
|
||||||
|
},
|
||||||
|
timeoutMs:
|
||||||
|
this.options.installTimeoutMs ??
|
||||||
|
DEFAULT_INSTALL_TIMEOUT_MS
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
throw packageManagerError(error)
|
||||||
|
}
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
const detail =
|
||||||
|
result.stderr.trim() ||
|
||||||
|
result.stdout.trim() ||
|
||||||
|
`exit code ${result.exitCode}`
|
||||||
|
throw new Error(
|
||||||
|
`DSH 插件安装失败:${detail.slice(0, 4_000)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const installedDirectory = packageDirectory(
|
||||||
|
input.destinationDirectory,
|
||||||
|
input.entry.package.name
|
||||||
|
)
|
||||||
|
const installedManifest = npmInstalledManifestSchema.parse(
|
||||||
|
JSON.parse(
|
||||||
|
await readFile(join(installedDirectory, 'package.json'), 'utf8')
|
||||||
|
) as unknown
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
installedManifest.name !== input.entry.package.name ||
|
||||||
|
installedManifest.version !== input.entry.package.version
|
||||||
|
) {
|
||||||
|
throw new Error('DSH 插件安装版本与市场选择不一致')
|
||||||
|
}
|
||||||
|
const entrypoint = join(
|
||||||
|
installedDirectory,
|
||||||
|
normalizeEntrypoint(installedManifest)
|
||||||
|
)
|
||||||
|
if (!(await stat(entrypoint)).isFile()) {
|
||||||
|
throw new Error('DSH 插件入口文件不存在')
|
||||||
|
}
|
||||||
|
const lock = JSON.parse(
|
||||||
|
await readFile(
|
||||||
|
join(input.destinationDirectory, 'package-lock.json'),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
) as {
|
||||||
|
packages?: Record<string, { integrity?: unknown }>
|
||||||
|
}
|
||||||
|
const lockKey = relative(
|
||||||
|
input.destinationDirectory,
|
||||||
|
installedDirectory
|
||||||
|
).replaceAll('\\', '/')
|
||||||
|
const installedIntegrity =
|
||||||
|
lock.packages?.[lockKey]?.integrity
|
||||||
|
if (
|
||||||
|
installedIntegrity !== manifest.dist.integrity
|
||||||
|
) {
|
||||||
|
throw new Error('DSH 插件 npm 完整性校验不一致')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
entrypoint: relative(
|
||||||
|
input.destinationDirectory,
|
||||||
|
entrypoint
|
||||||
|
).replaceAll('\\', '/'),
|
||||||
|
integrity: manifest.dist.integrity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveManifest(
|
||||||
|
entry: RuntimeExtensionCatalogEntry
|
||||||
|
): Promise<NpmVersionManifest> {
|
||||||
|
const registryUrl = (
|
||||||
|
this.options.registryUrl ?? NPM_REGISTRY_URL
|
||||||
|
).replace(/\/$/u, '')
|
||||||
|
const url = new URL(
|
||||||
|
`${registryUrl}/${encodeURIComponent(entry.package.name)}`
|
||||||
|
)
|
||||||
|
const packument = npmPackumentSchema.parse(
|
||||||
|
await fetchJson(
|
||||||
|
this.options.fetcher ?? fetch,
|
||||||
|
url,
|
||||||
|
this.options.requestTimeoutMs ??
|
||||||
|
DEFAULT_REQUEST_TIMEOUT_MS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const manifest = packument.versions[entry.package.version]
|
||||||
|
if (!manifest) {
|
||||||
|
throw new Error('DSH 插件精确版本未发布')
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
manifest.name !== entry.package.name ||
|
||||||
|
manifest.version !== entry.package.version
|
||||||
|
) {
|
||||||
|
throw new Error('DSH 插件 npm 元数据不一致')
|
||||||
|
}
|
||||||
|
return manifest
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -236,7 +236,7 @@ describe('GoodBuddy Harness internal control plane', () => {
|
|||||||
).toBeGreaterThan(180)
|
).toBeGreaterThan(180)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('blocks mutating and shell tools in Ask while allowing reads', async () => {
|
it('allows only the known read-only tools in Ask', async () => {
|
||||||
const { listeners, handle } = stubAgentContext()
|
const { listeners, handle } = stubAgentContext()
|
||||||
const executeTool = listeners.get('tools/execute')!
|
const executeTool = listeners.get('tools/execute')!
|
||||||
const next = vi.fn(async () => ({
|
const next = vi.fn(async () => ({
|
||||||
@@ -249,13 +249,46 @@ describe('GoodBuddy Harness internal control plane', () => {
|
|||||||
agent: handle.agent
|
agent: handle.agent
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const name of ['write', 'edit', 'bash', 'pwsh']) {
|
for (const name of [
|
||||||
|
'write',
|
||||||
|
'edit',
|
||||||
|
'bash',
|
||||||
|
'pwsh',
|
||||||
|
'third_party_deploy'
|
||||||
|
]) {
|
||||||
await expect(
|
await expect(
|
||||||
Promise.resolve(executeTool(request(name), next))
|
Promise.resolve(executeTool(request(name), next))
|
||||||
).rejects.toThrow('Ask 模式不允许')
|
).rejects.toThrow('Ask 模式不允许')
|
||||||
}
|
}
|
||||||
|
for (const name of ['read', 'skill']) {
|
||||||
await expect(
|
await expect(
|
||||||
Promise.resolve(executeTool(request('read'), next))
|
Promise.resolve(executeTool(request(name), next))
|
||||||
).resolves.toMatchObject({ isError: false })
|
).resolves.toMatchObject({ isError: false })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows every registered tool in Execute', async () => {
|
||||||
|
const { listeners, handle, internals } = stubAgentContext()
|
||||||
|
internals.sessions.get('session-output')!.inflight.mode =
|
||||||
|
'execute'
|
||||||
|
const executeTool = listeners.get('tools/execute')!
|
||||||
|
const next = vi.fn(async () => ({
|
||||||
|
isError: false,
|
||||||
|
value: {},
|
||||||
|
content: []
|
||||||
|
}))
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
Promise.resolve(
|
||||||
|
executeTool(
|
||||||
|
{
|
||||||
|
name: 'third_party_deploy',
|
||||||
|
agent: handle.agent
|
||||||
|
},
|
||||||
|
next
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).resolves.toMatchObject({ isError: false })
|
||||||
|
expect(next).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,12 +45,7 @@ const DELTA_BATCH_CHARACTERS = 4 * 1024
|
|||||||
const DELTA_BATCH_INTERVAL_MS = 100
|
const DELTA_BATCH_INTERVAL_MS = 100
|
||||||
const MAX_SUMMARY_CHARACTERS = 4_000
|
const MAX_SUMMARY_CHARACTERS = 4_000
|
||||||
const MAX_MCP_PROXY_RESULT_BYTES = 256 * 1024
|
const MAX_MCP_PROXY_RESULT_BYTES = 256 * 1024
|
||||||
const ASK_BLOCKED_TOOL_NAMES = new Set([
|
const ASK_READ_ONLY_TOOL_NAMES = new Set(['read', 'skill'])
|
||||||
'bash',
|
|
||||||
'pwsh',
|
|
||||||
'write',
|
|
||||||
'edit'
|
|
||||||
])
|
|
||||||
const GOODBUDDY_EXECUTION_GUIDANCE = [
|
const GOODBUDDY_EXECUTION_GUIDANCE = [
|
||||||
'GoodBuddy controlled execution rules:',
|
'GoodBuddy controlled execution rules:',
|
||||||
'- In Execute mode, act through the available tools instead of writing a long implementation plan.',
|
'- In Execute mode, act through the available tools instead of writing a long implementation plan.',
|
||||||
@@ -656,10 +651,10 @@ export class GoodBuddyHarnessControlPlane {
|
|||||||
record &&
|
record &&
|
||||||
record.handle.agent === exec.agent &&
|
record.handle.agent === exec.agent &&
|
||||||
record.inflight?.mode === 'ask' &&
|
record.inflight?.mode === 'ask' &&
|
||||||
ASK_BLOCKED_TOOL_NAMES.has(exec.name)
|
!ASK_READ_ONLY_TOOL_NAMES.has(exec.name)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Ask 模式不允许执行修改或命令工具:${exec.name}`
|
`Ask 模式不允许执行非只读工具:${exec.name}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return next()
|
return next()
|
||||||
@@ -710,7 +705,10 @@ export class GoodBuddyHarnessControlPlane {
|
|||||||
type: 'tool',
|
type: 'tool',
|
||||||
callId: toolResult?.toolCallId ?? 'unknown-tool-call',
|
callId: toolResult?.toolCallId ?? 'unknown-tool-call',
|
||||||
name: 'tool',
|
name: 'tool',
|
||||||
state: event.data.error ? 'failed' : 'completed',
|
state:
|
||||||
|
event.data.error || toolResult?.isError === true
|
||||||
|
? 'failed'
|
||||||
|
: 'completed',
|
||||||
output: boundedJson(event.data.message.content)
|
output: boundedJson(event.data.message.content)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { mkdtemp, rm } from 'node:fs/promises'
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
import { createServer, type Server } from 'node:http'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
||||||
import type { KnowledgeService } from '../knowledge/knowledge-service'
|
import type { KnowledgeService } from '../knowledge/knowledge-service'
|
||||||
import { AssistantDatabase } from '../assistant/assistant-database'
|
import { AssistantDatabase } from '../assistant/assistant-database'
|
||||||
import {
|
import {
|
||||||
@@ -64,6 +70,7 @@ function createService() {
|
|||||||
const gateways: KnowledgeMcpGateway[] = []
|
const gateways: KnowledgeMcpGateway[] = []
|
||||||
const databases: AssistantDatabase[] = []
|
const databases: AssistantDatabase[] = []
|
||||||
const temporaryDirectories: string[] = []
|
const temporaryDirectories: string[] = []
|
||||||
|
const httpServers: Server[] = []
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Promise.all(gateways.splice(0).map((gateway) => gateway.dispose()))
|
await Promise.all(gateways.splice(0).map((gateway) => gateway.dispose()))
|
||||||
@@ -75,6 +82,12 @@ afterEach(async () => {
|
|||||||
.splice(0)
|
.splice(0)
|
||||||
.map((directory) => rm(directory, { recursive: true, force: true }))
|
.map((directory) => rm(directory, { recursive: true, force: true }))
|
||||||
)
|
)
|
||||||
|
await Promise.all(
|
||||||
|
httpServers.splice(0).map(
|
||||||
|
(server) =>
|
||||||
|
new Promise<void>((resolve) => server.close(() => resolve()))
|
||||||
|
)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('KnowledgeMcpGateway', () => {
|
describe('KnowledgeMcpGateway', () => {
|
||||||
@@ -456,4 +469,122 @@ describe('KnowledgeMcpGateway', () => {
|
|||||||
})
|
})
|
||||||
expect(oversized.status).toBe(413)
|
expect(oversized.status).toBe(413)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('proxies custom MCP through a request-scoped loopback token without exposing the upstream credential', async () => {
|
||||||
|
const upstreamAuthorizations: Array<string | undefined> = []
|
||||||
|
const upstream = createServer(async (request, response) => {
|
||||||
|
upstreamAuthorizations.push(request.headers.authorization)
|
||||||
|
if (request.method !== 'POST') {
|
||||||
|
response.writeHead(405)
|
||||||
|
response.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of request) {
|
||||||
|
chunks.push(Buffer.from(chunk))
|
||||||
|
}
|
||||||
|
const body = JSON.parse(
|
||||||
|
Buffer.concat(chunks).toString('utf8')
|
||||||
|
) as unknown
|
||||||
|
const mcp = new McpServer({
|
||||||
|
name: 'private-upstream',
|
||||||
|
version: '1.0.0'
|
||||||
|
})
|
||||||
|
mcp.registerTool(
|
||||||
|
'echo_private',
|
||||||
|
{
|
||||||
|
description: 'Echo through the private server',
|
||||||
|
inputSchema: {
|
||||||
|
value: z.string().max(100)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async ({ value }) => ({
|
||||||
|
content: [{ type: 'text', text: `upstream:${value}` }]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: undefined
|
||||||
|
})
|
||||||
|
await mcp.connect(transport)
|
||||||
|
await transport.handleRequest(request, response, body)
|
||||||
|
await Promise.allSettled([transport.close(), mcp.close()])
|
||||||
|
})
|
||||||
|
httpServers.push(upstream)
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
upstream.once('error', reject)
|
||||||
|
upstream.listen(0, '127.0.0.1', resolve)
|
||||||
|
})
|
||||||
|
const address = upstream.address()
|
||||||
|
if (!address || typeof address === 'string') {
|
||||||
|
throw new Error('upstream did not bind')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { service } = createService()
|
||||||
|
const gateway = new KnowledgeMcpGateway(service)
|
||||||
|
gateways.push(gateway)
|
||||||
|
await gateway.start()
|
||||||
|
const controller = new AbortController()
|
||||||
|
const token = gateway.grantCustomMcp(
|
||||||
|
'custom-request',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: '00000000-0000-4000-8000-000000000091',
|
||||||
|
name: 'Private MCP',
|
||||||
|
description: '',
|
||||||
|
enabled: true,
|
||||||
|
allowDynamicTools: true,
|
||||||
|
assignments: ['opencode'],
|
||||||
|
secretConfigured: true,
|
||||||
|
secret: 'upstream-secret',
|
||||||
|
transport: 'http',
|
||||||
|
url: `http://127.0.0.1:${address.port}/mcp`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
controller.signal
|
||||||
|
)!
|
||||||
|
const client = new Client({
|
||||||
|
name: 'loopback-test-client',
|
||||||
|
version: '1.0.0'
|
||||||
|
})
|
||||||
|
await client.connect(
|
||||||
|
new StreamableHTTPClientTransport(
|
||||||
|
new URL(gateway.getEndpoint()!),
|
||||||
|
{
|
||||||
|
requestInit: {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const listed = await client.listTools()
|
||||||
|
expect(listed.tools).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: expect.stringMatching(
|
||||||
|
/^mcp_[a-f0-9]{8}_[a-f0-9]{8}_echo_private$/u
|
||||||
|
),
|
||||||
|
description: expect.stringContaining('Private MCP')
|
||||||
|
})
|
||||||
|
])
|
||||||
|
expect(JSON.stringify(listed)).not.toContain('upstream-secret')
|
||||||
|
expect(JSON.stringify(listed)).not.toContain(
|
||||||
|
`127.0.0.1:${address.port}`
|
||||||
|
)
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: listed.tools[0]!.name,
|
||||||
|
arguments: { value: 'hello' }
|
||||||
|
})
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
content: [{ type: 'text', text: 'upstream:hello' }]
|
||||||
|
})
|
||||||
|
expect(upstreamAuthorizations).toContain(
|
||||||
|
'Bearer upstream-secret'
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
gateway.revoke(token)
|
||||||
|
await client.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,8 +5,17 @@ import {
|
|||||||
type Server,
|
type Server,
|
||||||
type ServerResponse
|
type ServerResponse
|
||||||
} from 'node:http'
|
} from 'node:http'
|
||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||||
|
import { Server as McpProtocolServer } from '@modelcontextprotocol/sdk/server/index.js'
|
||||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
||||||
|
import {
|
||||||
|
CallToolRequestSchema,
|
||||||
|
CallToolResultSchema,
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
type CallToolResult,
|
||||||
|
type Tool
|
||||||
|
} from '@modelcontextprotocol/sdk/types.js'
|
||||||
|
import { z } from 'zod'
|
||||||
import type { KnowledgeSearchReference } from '../../shared/contracts'
|
import type { KnowledgeSearchReference } from '../../shared/contracts'
|
||||||
import { stripKnowledgeHighlightTags } from '../../shared/knowledge-text'
|
import { stripKnowledgeHighlightTags } from '../../shared/knowledge-text'
|
||||||
import {
|
import {
|
||||||
@@ -39,9 +48,26 @@ import type {
|
|||||||
GoodBuddyConfigApplyAuthorizer,
|
GoodBuddyConfigApplyAuthorizer,
|
||||||
GoodBuddyConfigService
|
GoodBuddyConfigService
|
||||||
} from '../goodbuddy-config-service'
|
} from '../goodbuddy-config-service'
|
||||||
|
import {
|
||||||
|
createMcpTransport
|
||||||
|
} from '../capabilities/mcp-client-transport'
|
||||||
|
import type {
|
||||||
|
ResolvedMcpServer
|
||||||
|
} from '../capabilities/capability-service'
|
||||||
|
import {
|
||||||
|
createMcpToolName,
|
||||||
|
isValidMcpToolName,
|
||||||
|
normalizeMcpToolSchema
|
||||||
|
} from './mcp-tool-utils'
|
||||||
|
|
||||||
const MAX_REQUEST_BODY_BYTES = 64 * 1024
|
const MAX_REQUEST_BODY_BYTES = 64 * 1024
|
||||||
const MAX_RESULT_BYTES = 128 * 1024
|
const MAX_RESULT_BYTES = 128 * 1024
|
||||||
|
const MAX_CUSTOM_MCP_RESULT_BYTES = 256 * 1024
|
||||||
|
const MAX_CUSTOM_MCP_SERVERS = 16
|
||||||
|
const MAX_CUSTOM_MCP_TOOLS = 100
|
||||||
|
const CUSTOM_MCP_TIMEOUT_MS = 30_000
|
||||||
|
const CUSTOM_MCP_MAX_TOTAL_TIMEOUT_MS = 5 * 60_000
|
||||||
|
const CUSTOM_MCP_TASK_CANCEL_TIMEOUT_MS = 5_000
|
||||||
const DEFAULT_CAPABILITY_TTL_MS = 10 * 60_000
|
const DEFAULT_CAPABILITY_TTL_MS = 10 * 60_000
|
||||||
const MAX_CAPABILITY_TTL_MS = 15 * 60_000
|
const MAX_CAPABILITY_TTL_MS = 15 * 60_000
|
||||||
|
|
||||||
@@ -140,10 +166,29 @@ type Capability = {
|
|||||||
authorizeConfigApply?: GoodBuddyConfigApplyAuthorizer
|
authorizeConfigApply?: GoodBuddyConfigApplyAuthorizer
|
||||||
expiresAt: number
|
expiresAt: number
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
|
brokerController: AbortController
|
||||||
|
customMcpServers: readonly ResolvedMcpServer[]
|
||||||
|
customMcpConnections?: Promise<CustomMcpConnection[]>
|
||||||
references: Map<string, KnowledgeSearchReference>
|
references: Map<string, KnowledgeSearchReference>
|
||||||
removeAbortListener: () => void
|
removeAbortListener: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CustomMcpBinding = {
|
||||||
|
client: Client
|
||||||
|
server: ResolvedMcpServer
|
||||||
|
originalName: string
|
||||||
|
exposedTool: Tool
|
||||||
|
taskSupport?: 'forbidden' | 'optional' | 'required'
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomMcpConnection = {
|
||||||
|
client: Client
|
||||||
|
server: ResolvedMcpServer
|
||||||
|
bindings: CustomMcpBinding[]
|
||||||
|
dynamicToolsSupported: boolean
|
||||||
|
dynamicToolsChanged: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type KnowledgeMcpGatewayOptions = {
|
export type KnowledgeMcpGatewayOptions = {
|
||||||
capabilityTtlMs?: number
|
capabilityTtlMs?: number
|
||||||
maximumBodyBytes?: number
|
maximumBodyBytes?: number
|
||||||
@@ -184,6 +229,38 @@ function referenceKey(reference: KnowledgeSearchReference): string {
|
|||||||
].join('\0')
|
].join('\0')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureBoundedCustomMcpResult(result: unknown): CallToolResult {
|
||||||
|
const normalized =
|
||||||
|
result &&
|
||||||
|
typeof result === 'object' &&
|
||||||
|
'toolResult' in result
|
||||||
|
? {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: JSON.stringify(
|
||||||
|
(result as { toolResult: unknown }).toolResult
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
: result
|
||||||
|
const parsed = CallToolResultSchema.parse(normalized)
|
||||||
|
let serialized: string
|
||||||
|
try {
|
||||||
|
serialized = JSON.stringify(parsed)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('MCP 工具结果无法序列化', { cause: error })
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!serialized ||
|
||||||
|
Buffer.byteLength(serialized) > MAX_CUSTOM_MCP_RESULT_BYTES
|
||||||
|
) {
|
||||||
|
throw new Error('MCP 工具结果超过 256KB 安全限制')
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
function sendJson(
|
function sendJson(
|
||||||
response: ServerResponse,
|
response: ServerResponse,
|
||||||
status: number,
|
status: number,
|
||||||
@@ -231,6 +308,7 @@ async function readBoundedJson(
|
|||||||
|
|
||||||
export class KnowledgeMcpGateway {
|
export class KnowledgeMcpGateway {
|
||||||
private readonly capabilities = new Map<string, Capability>()
|
private readonly capabilities = new Map<string, Capability>()
|
||||||
|
private readonly customMcpCleanups = new Set<Promise<void>>()
|
||||||
private readonly now: () => number
|
private readonly now: () => number
|
||||||
private readonly capabilityTtlMs: number
|
private readonly capabilityTtlMs: number
|
||||||
private readonly maximumBodyBytes: number
|
private readonly maximumBodyBytes: number
|
||||||
@@ -323,15 +401,9 @@ export class KnowledgeMcpGateway {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
signal.throwIfAborted()
|
signal.throwIfAborted()
|
||||||
const libraryIds = Object.freeze([...new Set(authorizedLibraryIds)])
|
return this.storeCapability({
|
||||||
const token = randomBytes(32).toString('base64url')
|
|
||||||
const abort = (): void => {
|
|
||||||
this.revoke(token)
|
|
||||||
}
|
|
||||||
signal.addEventListener('abort', abort, { once: true })
|
|
||||||
this.capabilities.set(token, {
|
|
||||||
requestId,
|
requestId,
|
||||||
libraryIds,
|
libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]),
|
||||||
magicNotesAccess: effectiveMagicNotesAccess,
|
magicNotesAccess: effectiveMagicNotesAccess,
|
||||||
configAccess: effectiveConfigAccess,
|
configAccess: effectiveConfigAccess,
|
||||||
...(effectiveConfigAccess !== 'none'
|
...(effectiveConfigAccess !== 'none'
|
||||||
@@ -340,11 +412,67 @@ export class KnowledgeMcpGateway {
|
|||||||
authorizeConfigApply: config?.authorizeApply
|
authorizeConfigApply: config?.authorizeApply
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
expiresAt: this.now() + this.capabilityTtlMs,
|
|
||||||
signal,
|
signal,
|
||||||
|
customMcpServers: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
grantCustomMcp(
|
||||||
|
requestId: string,
|
||||||
|
servers: readonly ResolvedMcpServer[],
|
||||||
|
signal: AbortSignal
|
||||||
|
): string | undefined {
|
||||||
|
if (servers.length === 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (servers.length > MAX_CUSTOM_MCP_SERVERS) {
|
||||||
|
throw new Error(
|
||||||
|
`Agent Runtime 最多可加载 ${MAX_CUSTOM_MCP_SERVERS} 个 MCP Server`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
servers.some(
|
||||||
|
(server) =>
|
||||||
|
!server.enabled ||
|
||||||
|
server.assignments.length === 0
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error('Agent Runtime MCP 授权包含无效 Server')
|
||||||
|
}
|
||||||
|
return this.storeCapability({
|
||||||
|
requestId,
|
||||||
|
libraryIds: [],
|
||||||
|
magicNotesAccess: 'none',
|
||||||
|
configAccess: 'none',
|
||||||
|
signal,
|
||||||
|
customMcpServers: Object.freeze([...servers])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private storeCapability(
|
||||||
|
value: Omit<
|
||||||
|
Capability,
|
||||||
|
| 'expiresAt'
|
||||||
|
| 'references'
|
||||||
|
| 'removeAbortListener'
|
||||||
|
| 'brokerController'
|
||||||
|
| 'customMcpConnections'
|
||||||
|
>
|
||||||
|
): string {
|
||||||
|
value.signal.throwIfAborted()
|
||||||
|
const token = randomBytes(32).toString('base64url')
|
||||||
|
const brokerController = new AbortController()
|
||||||
|
const abort = (): void => {
|
||||||
|
this.revoke(token)
|
||||||
|
}
|
||||||
|
value.signal.addEventListener('abort', abort, { once: true })
|
||||||
|
this.capabilities.set(token, {
|
||||||
|
...value,
|
||||||
|
expiresAt: this.now() + this.capabilityTtlMs,
|
||||||
|
brokerController,
|
||||||
references: new Map(),
|
references: new Map(),
|
||||||
removeAbortListener: () =>
|
removeAbortListener: () =>
|
||||||
signal.removeEventListener('abort', abort)
|
value.signal.removeEventListener('abort', abort)
|
||||||
})
|
})
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
@@ -359,8 +487,18 @@ export class KnowledgeMcpGateway {
|
|||||||
}
|
}
|
||||||
capability.removeAbortListener()
|
capability.removeAbortListener()
|
||||||
this.capabilities.delete(token)
|
this.capabilities.delete(token)
|
||||||
|
capability.brokerController.abort(
|
||||||
|
new Error('MCP capability was revoked')
|
||||||
|
)
|
||||||
|
if (capability.configAccess !== 'none') {
|
||||||
this.configService?.revokeRequest(capability.requestId)
|
this.configService?.revokeRequest(capability.requestId)
|
||||||
}
|
}
|
||||||
|
const cleanup = this.closeCustomMcpConnections(capability)
|
||||||
|
this.customMcpCleanups.add(cleanup)
|
||||||
|
void cleanup.finally(() => {
|
||||||
|
this.customMcpCleanups.delete(cleanup)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
drainReferences(
|
drainReferences(
|
||||||
token: string | undefined
|
token: string | undefined
|
||||||
@@ -514,6 +652,291 @@ export class KnowledgeMcpGateway {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createCustomMcpBindings(
|
||||||
|
client: Client,
|
||||||
|
server: ResolvedMcpServer,
|
||||||
|
tools: Awaited<ReturnType<Client['listTools']>>['tools']
|
||||||
|
): CustomMcpBinding[] {
|
||||||
|
if (tools.length > MAX_CUSTOM_MCP_TOOLS) {
|
||||||
|
throw new Error(
|
||||||
|
`MCP Server「${server.name}」提供的工具数量超过安全限制`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return tools.map((tool) => {
|
||||||
|
if (!isValidMcpToolName(tool.name)) {
|
||||||
|
throw new Error(
|
||||||
|
`MCP Server「${server.name}」返回了无效工具名称`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
server,
|
||||||
|
originalName: tool.name,
|
||||||
|
taskSupport: tool.execution?.taskSupport,
|
||||||
|
exposedTool: {
|
||||||
|
name: createMcpToolName(server.id, tool.name),
|
||||||
|
title: `${server.name} / ${tool.name}`.slice(0, 200),
|
||||||
|
description: [
|
||||||
|
`GoodBuddy 代理的自定义 MCP Server「${server.name}」工具。`,
|
||||||
|
tool.description
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.slice(0, 1_000),
|
||||||
|
inputSchema: normalizeMcpToolSchema(tool.inputSchema),
|
||||||
|
annotations: tool.annotations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connectCustomMcpServer(
|
||||||
|
capability: Capability,
|
||||||
|
server: ResolvedMcpServer
|
||||||
|
): Promise<CustomMcpConnection> {
|
||||||
|
let connection: CustomMcpConnection | undefined
|
||||||
|
const client = new Client(
|
||||||
|
{
|
||||||
|
name: 'goodbuddy-main-mcp-broker',
|
||||||
|
version: '1.0.0'
|
||||||
|
},
|
||||||
|
server.allowDynamicTools
|
||||||
|
? {
|
||||||
|
listChanged: {
|
||||||
|
tools: {
|
||||||
|
autoRefresh: false,
|
||||||
|
debounceMs: 0,
|
||||||
|
onChanged: (error) => {
|
||||||
|
if (!error && connection) {
|
||||||
|
connection.dynamicToolsChanged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
const signal = AbortSignal.any([
|
||||||
|
capability.signal,
|
||||||
|
capability.brokerController.signal
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
await client.connect(createMcpTransport(server), {
|
||||||
|
timeout: CUSTOM_MCP_TIMEOUT_MS,
|
||||||
|
signal
|
||||||
|
})
|
||||||
|
const result = await client.listTools(undefined, {
|
||||||
|
timeout: CUSTOM_MCP_TIMEOUT_MS,
|
||||||
|
signal
|
||||||
|
})
|
||||||
|
connection = {
|
||||||
|
client,
|
||||||
|
server,
|
||||||
|
bindings: this.createCustomMcpBindings(
|
||||||
|
client,
|
||||||
|
server,
|
||||||
|
result.tools
|
||||||
|
),
|
||||||
|
dynamicToolsSupported:
|
||||||
|
server.allowDynamicTools &&
|
||||||
|
client.getServerCapabilities()?.tools?.listChanged === true,
|
||||||
|
dynamicToolsChanged: false
|
||||||
|
}
|
||||||
|
return connection
|
||||||
|
} catch (error) {
|
||||||
|
await client.close().catch(() => undefined)
|
||||||
|
throw new Error(
|
||||||
|
`无法加载 MCP Server「${server.name}」的工具`,
|
||||||
|
{ cause: error }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCustomMcpBindings(
|
||||||
|
token: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
refreshDynamic = true
|
||||||
|
): Promise<Map<string, CustomMcpBinding>> {
|
||||||
|
const capability = this.getCapability(token)
|
||||||
|
if (capability.customMcpServers.length === 0) {
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
if (!capability.customMcpConnections) {
|
||||||
|
capability.customMcpConnections = (async () => {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
capability.customMcpServers.map((server) =>
|
||||||
|
this.connectCustomMcpServer(capability, server)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const connections = results.flatMap((result) =>
|
||||||
|
result.status === 'fulfilled' ? [result.value] : []
|
||||||
|
)
|
||||||
|
const failure = results.find(
|
||||||
|
(result) => result.status === 'rejected'
|
||||||
|
)
|
||||||
|
if (failure?.status === 'rejected') {
|
||||||
|
await Promise.allSettled(
|
||||||
|
connections.map((connection) => connection.client.close())
|
||||||
|
)
|
||||||
|
throw failure.reason
|
||||||
|
}
|
||||||
|
return connections
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
let connections: CustomMcpConnection[]
|
||||||
|
try {
|
||||||
|
connections = await capability.customMcpConnections
|
||||||
|
} catch (error) {
|
||||||
|
capability.customMcpConnections = undefined
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (refreshDynamic) {
|
||||||
|
const effectiveSignal = signal
|
||||||
|
? AbortSignal.any([
|
||||||
|
signal,
|
||||||
|
capability.signal,
|
||||||
|
capability.brokerController.signal
|
||||||
|
])
|
||||||
|
: AbortSignal.any([
|
||||||
|
capability.signal,
|
||||||
|
capability.brokerController.signal
|
||||||
|
])
|
||||||
|
for (const connection of connections) {
|
||||||
|
if (
|
||||||
|
!connection.dynamicToolsSupported ||
|
||||||
|
!connection.dynamicToolsChanged
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
connection.dynamicToolsChanged = false
|
||||||
|
try {
|
||||||
|
const result = await connection.client.listTools(undefined, {
|
||||||
|
timeout: CUSTOM_MCP_TIMEOUT_MS,
|
||||||
|
signal: effectiveSignal
|
||||||
|
})
|
||||||
|
connection.bindings = this.createCustomMcpBindings(
|
||||||
|
connection.client,
|
||||||
|
connection.server,
|
||||||
|
result.tools
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
connection.dynamicToolsChanged = true
|
||||||
|
throw new Error(
|
||||||
|
`无法刷新 MCP Server「${connection.server.name}」的工具`,
|
||||||
|
{ cause: error }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bindings = new Map<string, CustomMcpBinding>()
|
||||||
|
for (const connection of connections) {
|
||||||
|
for (const binding of connection.bindings) {
|
||||||
|
if (bindings.size >= MAX_CUSTOM_MCP_TOOLS) {
|
||||||
|
throw new Error('Agent Runtime MCP 工具总数超过 100 个安全限制')
|
||||||
|
}
|
||||||
|
if (bindings.has(binding.exposedTool.name)) {
|
||||||
|
throw new Error('Agent Runtime MCP 工具名称发生冲突')
|
||||||
|
}
|
||||||
|
bindings.set(binding.exposedTool.name, binding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bindings
|
||||||
|
}
|
||||||
|
|
||||||
|
async prepareCustomMcpTools(
|
||||||
|
token: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<Tool[]> {
|
||||||
|
return [
|
||||||
|
...(await this.getCustomMcpBindings(token, signal)).values()
|
||||||
|
].map((binding) => binding.exposedTool)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callCustomMcpTool(
|
||||||
|
token: string,
|
||||||
|
binding: CustomMcpBinding,
|
||||||
|
argumentsValue: Record<string, unknown>,
|
||||||
|
signal: AbortSignal
|
||||||
|
): Promise<CallToolResult> {
|
||||||
|
const capability = this.getCapability(token)
|
||||||
|
const effectiveSignal = AbortSignal.any([
|
||||||
|
signal,
|
||||||
|
capability.signal,
|
||||||
|
capability.brokerController.signal
|
||||||
|
])
|
||||||
|
const params = {
|
||||||
|
name: binding.originalName,
|
||||||
|
arguments: argumentsValue
|
||||||
|
}
|
||||||
|
const options = {
|
||||||
|
timeout: CUSTOM_MCP_TIMEOUT_MS,
|
||||||
|
signal: effectiveSignal,
|
||||||
|
onprogress: () => undefined,
|
||||||
|
resetTimeoutOnProgress: true,
|
||||||
|
maxTotalTimeout: CUSTOM_MCP_MAX_TOTAL_TIMEOUT_MS
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (binding.taskSupport !== 'required') {
|
||||||
|
return ensureBoundedCustomMcpResult(
|
||||||
|
await binding.client.callTool(params, undefined, options)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let taskId: string | undefined
|
||||||
|
try {
|
||||||
|
for await (const message of binding.client.experimental.tasks.callToolStream(
|
||||||
|
params,
|
||||||
|
undefined,
|
||||||
|
options
|
||||||
|
)) {
|
||||||
|
if (
|
||||||
|
(message.type === 'taskCreated' ||
|
||||||
|
message.type === 'taskStatus') &&
|
||||||
|
typeof message.task.taskId === 'string'
|
||||||
|
) {
|
||||||
|
taskId = message.task.taskId
|
||||||
|
} else if (message.type === 'result') {
|
||||||
|
return ensureBoundedCustomMcpResult(message.result)
|
||||||
|
} else if (message.type === 'error') {
|
||||||
|
throw message.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('MCP 任务工具未返回最终结果')
|
||||||
|
} catch (error) {
|
||||||
|
if (taskId) {
|
||||||
|
await binding.client.experimental.tasks
|
||||||
|
.cancelTask(taskId, {
|
||||||
|
timeout: CUSTOM_MCP_TASK_CANCEL_TIMEOUT_MS,
|
||||||
|
maxTotalTimeout: CUSTOM_MCP_TASK_CANCEL_TIMEOUT_MS
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (effectiveSignal.aborted) {
|
||||||
|
throw effectiveSignal.reason
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`MCP Server「${binding.server.name}」工具调用失败`,
|
||||||
|
{ cause: error }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async closeCustomMcpConnections(
|
||||||
|
capability: Capability
|
||||||
|
): Promise<void> {
|
||||||
|
const pending = capability.customMcpConnections
|
||||||
|
capability.customMcpConnections = undefined
|
||||||
|
if (!pending) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const connections = await pending.catch(() => [])
|
||||||
|
await Promise.allSettled(
|
||||||
|
connections.map((connection) => connection.client.close())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private requireConfig(
|
private requireConfig(
|
||||||
token: string,
|
token: string,
|
||||||
requiredAccess: Exclude<MagicNotesCapabilityAccess, 'none'>
|
requiredAccess: Exclude<MagicNotesCapabilityAccess, 'none'>
|
||||||
@@ -841,22 +1264,46 @@ export class KnowledgeMcpGateway {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const mcp = new McpServer({
|
const availableScopedTools = new Set(
|
||||||
name: 'goodbuddy-scoped-knowledge',
|
this.getAvailableToolNames(token)
|
||||||
version: '1.0.0'
|
)
|
||||||
})
|
const mcp = new McpProtocolServer(
|
||||||
const availableTools = this.getAvailableToolNames(token)
|
|
||||||
for (const name of availableTools) {
|
|
||||||
const definition = scopedDataToolByName.get(name)
|
|
||||||
if (!definition) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
mcp.registerTool(
|
|
||||||
name,
|
|
||||||
{
|
{
|
||||||
|
name: 'goodbuddy-request-scoped-capabilities',
|
||||||
|
version: '1.0.0'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mcp.setRequestHandler(
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
async (_request, extra) => {
|
||||||
|
const customBindings = await this.getCustomMcpBindings(
|
||||||
|
token,
|
||||||
|
extra.signal
|
||||||
|
)
|
||||||
|
const scopedTools = [...availableScopedTools].flatMap(
|
||||||
|
(name): Tool[] => {
|
||||||
|
const definition = scopedDataToolByName.get(
|
||||||
|
name as ScopedDataToolName
|
||||||
|
)
|
||||||
|
if (!definition) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const inputSchema = z.toJSONSchema(
|
||||||
|
definition.inputSchema,
|
||||||
|
{ target: 'draft-7' }
|
||||||
|
) as Tool['inputSchema'] & { $schema?: string }
|
||||||
|
Reflect.deleteProperty(inputSchema, '$schema')
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name,
|
||||||
title: definition.title,
|
title: definition.title,
|
||||||
description: definition.description,
|
description: definition.description,
|
||||||
inputSchema: definition.inputSchema,
|
inputSchema,
|
||||||
annotations: {
|
annotations: {
|
||||||
readOnlyHint: definition.access === 'read',
|
readOnlyHint: definition.access === 'read',
|
||||||
destructiveHint:
|
destructiveHint:
|
||||||
@@ -864,17 +1311,64 @@ export class KnowledgeMcpGateway {
|
|||||||
name === 'note_delete' ||
|
name === 'note_delete' ||
|
||||||
name === 'note_entry_delete'
|
name === 'note_entry_delete'
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
async (input: Record<string, unknown>) => ({
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
tools: [
|
||||||
|
...scopedTools,
|
||||||
|
...[...customBindings.values()].map(
|
||||||
|
(binding) => binding.exposedTool
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mcp.setRequestHandler(
|
||||||
|
CallToolRequestSchema,
|
||||||
|
async (call, extra) => {
|
||||||
|
const name = call.params.name
|
||||||
|
const input = call.params.arguments ?? {}
|
||||||
|
if (
|
||||||
|
availableScopedTools.has(name as ScopedDataToolName)
|
||||||
|
) {
|
||||||
|
const definition = scopedDataToolByName.get(
|
||||||
|
name as ScopedDataToolName
|
||||||
|
)
|
||||||
|
if (!definition) {
|
||||||
|
throw new Error('GoodBuddy 工具不存在')
|
||||||
|
}
|
||||||
|
const parsedInput = definition.inputSchema.parse(input)
|
||||||
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text' as const,
|
type: 'text' as const,
|
||||||
text: JSON.stringify(await this.callScopedTool(token, name, input))
|
text: JSON.stringify(
|
||||||
}
|
await this.callScopedTool(
|
||||||
]
|
token,
|
||||||
})
|
name as ScopedDataToolName,
|
||||||
|
parsedInput
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const binding = (
|
||||||
|
await this.getCustomMcpBindings(token, extra.signal)
|
||||||
|
).get(name)
|
||||||
|
if (!binding) {
|
||||||
|
throw new Error('GoodBuddy MCP 工具不存在或已失效')
|
||||||
|
}
|
||||||
|
return this.callCustomMcpTool(
|
||||||
|
token,
|
||||||
|
binding,
|
||||||
|
input,
|
||||||
|
extra.signal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
const transport = new StreamableHTTPServerTransport({
|
const transport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: undefined
|
sessionIdGenerator: undefined
|
||||||
})
|
})
|
||||||
@@ -897,6 +1391,7 @@ export class KnowledgeMcpGateway {
|
|||||||
for (const token of [...this.capabilities.keys()]) {
|
for (const token of [...this.capabilities.keys()]) {
|
||||||
this.revoke(token)
|
this.revoke(token)
|
||||||
}
|
}
|
||||||
|
await Promise.allSettled([...this.customMcpCleanups])
|
||||||
const server = this.server
|
const server = this.server
|
||||||
this.server = undefined
|
this.server = undefined
|
||||||
this.endpoint = undefined
|
this.endpoint = undefined
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
|
||||||
|
const MAXIMUM_MCP_TOOL_SCHEMA_BYTES = 32 * 1024
|
||||||
|
|
||||||
|
export function isValidMcpToolName(value: unknown): value is string {
|
||||||
|
return (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
value.length > 0 &&
|
||||||
|
value.length <= 128 &&
|
||||||
|
![...value].some((character) => {
|
||||||
|
const code = character.charCodeAt(0)
|
||||||
|
return code <= 31 || code === 127
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMcpToolName(
|
||||||
|
serverId: string,
|
||||||
|
originalName: string
|
||||||
|
): string {
|
||||||
|
const serverHash = createHash('sha256')
|
||||||
|
.update(serverId)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 8)
|
||||||
|
const toolHash = createHash('sha256')
|
||||||
|
.update(originalName)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 8)
|
||||||
|
const readable =
|
||||||
|
originalName
|
||||||
|
.replace(/[^a-zA-Z0-9_-]+/gu, '_')
|
||||||
|
.replace(/^_+|_+$/gu, '')
|
||||||
|
.slice(0, 36) || 'tool'
|
||||||
|
return `mcp_${serverHash}_${toolHash}_${readable}`.slice(0, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMcpToolSchema(
|
||||||
|
value: unknown
|
||||||
|
): Record<string, unknown> & { type: 'object' } {
|
||||||
|
let serialized: string
|
||||||
|
try {
|
||||||
|
serialized = JSON.stringify(value)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('MCP 工具参数结构无效', { cause: error })
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!serialized ||
|
||||||
|
Buffer.byteLength(serialized) >
|
||||||
|
MAXIMUM_MCP_TOOL_SCHEMA_BYTES
|
||||||
|
) {
|
||||||
|
throw new Error('MCP 工具参数结构超过 32KB 安全限制')
|
||||||
|
}
|
||||||
|
const schema = JSON.parse(serialized) as unknown
|
||||||
|
if (
|
||||||
|
!schema ||
|
||||||
|
typeof schema !== 'object' ||
|
||||||
|
Array.isArray(schema) ||
|
||||||
|
(schema as Record<string, unknown>).type !== 'object'
|
||||||
|
) {
|
||||||
|
throw new Error('MCP 工具参数必须使用 object JSON Schema')
|
||||||
|
}
|
||||||
|
return schema as Record<string, unknown> & { type: 'object' }
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||||
import { createHash, randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import {
|
import {
|
||||||
lstat,
|
lstat,
|
||||||
open,
|
open,
|
||||||
@@ -38,10 +38,14 @@ import {
|
|||||||
} from '../browser/browser-model-tools'
|
} from '../browser/browser-model-tools'
|
||||||
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
|
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
|
||||||
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||||
|
import {
|
||||||
|
createMcpToolName,
|
||||||
|
isValidMcpToolName,
|
||||||
|
normalizeMcpToolSchema
|
||||||
|
} from './mcp-tool-utils'
|
||||||
|
|
||||||
const MAX_MODEL_TOOLS = 100
|
const MAX_MODEL_TOOLS = 100
|
||||||
const MAX_MCP_SERVERS = 16
|
const MAX_MCP_SERVERS = 16
|
||||||
const MAX_TOOL_SCHEMA_BYTES = 32 * 1024
|
|
||||||
const MAX_TOOL_RESULT_BYTES = 256 * 1024
|
const MAX_TOOL_RESULT_BYTES = 256 * 1024
|
||||||
const MAX_READ_BYTES = 256 * 1024
|
const MAX_READ_BYTES = 256 * 1024
|
||||||
const MAX_WRITE_BYTES = 512 * 1024
|
const MAX_WRITE_BYTES = 512 * 1024
|
||||||
@@ -293,47 +297,6 @@ function boundedJson(value: unknown, errorMessage: string): string {
|
|||||||
return serialized
|
return serialized
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeToolSchema(value: unknown): Record<string, unknown> {
|
|
||||||
let serialized: string
|
|
||||||
try {
|
|
||||||
serialized = JSON.stringify(value)
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error('MCP 工具参数结构无效', { cause: error })
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!serialized ||
|
|
||||||
Buffer.byteLength(serialized) > MAX_TOOL_SCHEMA_BYTES
|
|
||||||
) {
|
|
||||||
throw new Error('MCP 工具参数结构超过 32KB 安全限制')
|
|
||||||
}
|
|
||||||
const schema = JSON.parse(serialized) as unknown
|
|
||||||
if (
|
|
||||||
!schema ||
|
|
||||||
typeof schema !== 'object' ||
|
|
||||||
Array.isArray(schema) ||
|
|
||||||
(schema as Record<string, unknown>).type !== 'object'
|
|
||||||
) {
|
|
||||||
throw new Error('MCP 工具参数必须使用 object JSON Schema')
|
|
||||||
}
|
|
||||||
return schema as Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMcpToolName(serverId: string, originalName: string): string {
|
|
||||||
const serverHash = createHash('sha256')
|
|
||||||
.update(serverId)
|
|
||||||
.digest('hex')
|
|
||||||
.slice(0, 8)
|
|
||||||
const toolHash = createHash('sha256')
|
|
||||||
.update(originalName)
|
|
||||||
.digest('hex')
|
|
||||||
.slice(0, 8)
|
|
||||||
const readable = originalName
|
|
||||||
.replace(/[^a-zA-Z0-9_-]+/gu, '_')
|
|
||||||
.replace(/^_+|_+$/gu, '')
|
|
||||||
.slice(0, 36) || 'tool'
|
|
||||||
return `mcp_${serverHash}_${toolHash}_${readable}`.slice(0, 64)
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTextToolResult(text: string): ModelToolResult {
|
function createTextToolResult(text: string): ModelToolResult {
|
||||||
const contextBytes = Buffer.byteLength(text)
|
const contextBytes = Buffer.byteLength(text)
|
||||||
if (contextBytes > MAX_TOOL_RESULT_BYTES) {
|
if (contextBytes > MAX_TOOL_RESULT_BYTES) {
|
||||||
@@ -852,7 +815,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
.slice(0, 1_000),
|
.slice(0, 1_000),
|
||||||
inputSchema: normalizeToolSchema(tool.inputSchema),
|
inputSchema: normalizeMcpToolSchema(tool.inputSchema),
|
||||||
source: 'mcp',
|
source: 'mcp',
|
||||||
serverName: server.name,
|
serverName: server.name,
|
||||||
taskSupport: tool.execution?.taskSupport
|
taskSupport: tool.execution?.taskSupport
|
||||||
@@ -860,13 +823,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
|||||||
}))
|
}))
|
||||||
if (
|
if (
|
||||||
bindings.some(
|
bindings.some(
|
||||||
(tool) =>
|
(tool) => !isValidMcpToolName(tool.originalName)
|
||||||
!tool.originalName ||
|
|
||||||
tool.originalName.length > 128 ||
|
|
||||||
[...tool.originalName].some((character) => {
|
|
||||||
const code = character.charCodeAt(0)
|
|
||||||
return code <= 31 || code === 127
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
throw new Error(`MCP Server「${server.name}」返回了无效工具名称`)
|
throw new Error(`MCP Server「${server.name}」返回了无效工具名称`)
|
||||||
|
|||||||
@@ -1307,6 +1307,147 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('OpenCodeRuntime embedded permission mediation', () => {
|
describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||||
|
it('shares assigned custom MCP only with embedded Execute through a scoped loopback token', async () => {
|
||||||
|
const setup = runClient([
|
||||||
|
{
|
||||||
|
id: 'idle',
|
||||||
|
type: 'session.idle',
|
||||||
|
properties: { sessionID: 'session-1' }
|
||||||
|
}
|
||||||
|
])
|
||||||
|
const gateway = {
|
||||||
|
getEndpoint: vi.fn(() => 'http://127.0.0.1:4567/mcp'),
|
||||||
|
grantCustomMcp: vi.fn(() => 'custom-capability'),
|
||||||
|
prepareCustomMcpTools: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
name: 'mcp_12345678_abcdef01_private_tool',
|
||||||
|
inputSchema: { type: 'object' }
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
revoke: vi.fn()
|
||||||
|
} as unknown as KnowledgeMcpGateway
|
||||||
|
const runtime = embeddedRuntime(setup.client, {
|
||||||
|
knowledgeGateway: gateway,
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: '00000000-0000-4000-8000-000000000092',
|
||||||
|
name: 'Private MCP',
|
||||||
|
description: '',
|
||||||
|
enabled: true,
|
||||||
|
allowDynamicTools: false,
|
||||||
|
assignments: ['opencode'],
|
||||||
|
secretConfigured: true,
|
||||||
|
secret: 'must-stay-in-main',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://private.example/mcp'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
await collectRun(runtime, 'execute')
|
||||||
|
|
||||||
|
expect(gateway.grantCustomMcp).toHaveBeenCalledWith(
|
||||||
|
'3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
||||||
|
expect.any(Array),
|
||||||
|
expect.any(AbortSignal)
|
||||||
|
)
|
||||||
|
expect(setup.client.mcp.add).toHaveBeenCalledWith({
|
||||||
|
directory: process.cwd(),
|
||||||
|
name: expect.stringMatching(/^goodbuddy-custom-[a-f0-9]{20}$/u),
|
||||||
|
config: {
|
||||||
|
type: 'remote',
|
||||||
|
url: 'http://127.0.0.1:4567/mcp',
|
||||||
|
enabled: true,
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer custom-capability'
|
||||||
|
},
|
||||||
|
oauth: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(JSON.stringify(
|
||||||
|
(setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>)
|
||||||
|
.mock.calls
|
||||||
|
)).not.toContain('must-stay-in-main')
|
||||||
|
expect(JSON.stringify(
|
||||||
|
(setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>)
|
||||||
|
.mock.calls
|
||||||
|
)).not.toContain('private.example')
|
||||||
|
expect(gateway.revoke).toHaveBeenCalledWith('custom-capability')
|
||||||
|
await runtime.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['ask', true] as const,
|
||||||
|
['execute', false] as const
|
||||||
|
])(
|
||||||
|
'does not share custom MCP with OpenCode in %s mode when embedded is %s',
|
||||||
|
async (workMode, embedded) => {
|
||||||
|
const setup = runClient([
|
||||||
|
{
|
||||||
|
id: 'idle',
|
||||||
|
type: 'session.idle',
|
||||||
|
properties: { sessionID: 'session-1' }
|
||||||
|
}
|
||||||
|
])
|
||||||
|
const gateway = {
|
||||||
|
getEndpoint: vi.fn(() => 'http://127.0.0.1:4567/mcp'),
|
||||||
|
grantCustomMcp: vi.fn(() => 'custom-capability'),
|
||||||
|
prepareCustomMcpTools: vi.fn(async () => []),
|
||||||
|
revoke: vi.fn()
|
||||||
|
} as unknown as KnowledgeMcpGateway
|
||||||
|
const runtime = embedded
|
||||||
|
? embeddedRuntime(setup.client, {
|
||||||
|
knowledgeGateway: gateway,
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: '00000000-0000-4000-8000-000000000093',
|
||||||
|
name: 'Private MCP',
|
||||||
|
description: '',
|
||||||
|
enabled: true,
|
||||||
|
allowDynamicTools: false,
|
||||||
|
assignments: ['opencode'],
|
||||||
|
secretConfigured: false,
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'private-command',
|
||||||
|
args: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
: new OpenCodeRuntime(
|
||||||
|
options({
|
||||||
|
baseUrl: 'http://127.0.0.1:4096',
|
||||||
|
embedded: false,
|
||||||
|
knowledgeGateway: gateway,
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: '00000000-0000-4000-8000-000000000093',
|
||||||
|
name: 'Private MCP',
|
||||||
|
description: '',
|
||||||
|
enabled: true,
|
||||||
|
allowDynamicTools: false,
|
||||||
|
assignments: ['opencode'],
|
||||||
|
secretConfigured: false,
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'private-command',
|
||||||
|
args: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
dependencies(fakeChild(), {
|
||||||
|
createClient: vi.fn(
|
||||||
|
() => setup.client
|
||||||
|
) as unknown as typeof createOpencodeClient
|
||||||
|
}).deps
|
||||||
|
)
|
||||||
|
|
||||||
|
await collectRun(runtime, workMode)
|
||||||
|
|
||||||
|
expect(gateway.grantCustomMcp).not.toHaveBeenCalled()
|
||||||
|
expect(setup.client.mcp.add).not.toHaveBeenCalled()
|
||||||
|
await runtime.dispose()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
it('parses OpenCode questions and sends the selected answers back', async () => {
|
it('parses OpenCode questions and sends the selected answers back', async () => {
|
||||||
const setup = runClient([
|
const setup = runClient([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ import {
|
|||||||
boundedToolDetail,
|
boundedToolDetail,
|
||||||
safeToolErrorDetail
|
safeToolErrorDetail
|
||||||
} from './approval-summary'
|
} from './approval-summary'
|
||||||
import type { RuntimeSkillPackage } from '../capabilities/capability-service'
|
import type {
|
||||||
|
ResolvedMcpServer,
|
||||||
|
RuntimeSkillPackage
|
||||||
|
} from '../capabilities/capability-service'
|
||||||
import { stageRuntimeSkillPackages } from './runtime-skill-packages'
|
import { stageRuntimeSkillPackages } from './runtime-skill-packages'
|
||||||
|
|
||||||
const MAX_STARTUP_OUTPUT_BYTES = 64 * 1024
|
const MAX_STARTUP_OUTPUT_BYTES = 64 * 1024
|
||||||
@@ -385,6 +388,7 @@ export type OpenCodeRuntimeOptions = {
|
|||||||
skillInstructions?: string
|
skillInstructions?: string
|
||||||
skillPackages?: RuntimeSkillPackage[]
|
skillPackages?: RuntimeSkillPackage[]
|
||||||
knowledgeGateway?: KnowledgeMcpGateway
|
knowledgeGateway?: KnowledgeMcpGateway
|
||||||
|
mcpServers?: ResolvedMcpServer[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSkillPermissionRules(
|
function createSkillPermissionRules(
|
||||||
@@ -1082,6 +1086,8 @@ export class OpenCodeRuntime implements AgentRuntime {
|
|||||||
createSkillPermissionRules(nativeSkillIds)
|
createSkillPermissionRules(nativeSkillIds)
|
||||||
let knowledgeMcpName: string | undefined
|
let knowledgeMcpName: string | undefined
|
||||||
let knowledgeToolIds: string[] = []
|
let knowledgeToolIds: string[] = []
|
||||||
|
let customMcpName: string | undefined
|
||||||
|
let customMcpToken: string | undefined
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
request.knowledgeCapabilityToken &&
|
request.knowledgeCapabilityToken &&
|
||||||
@@ -1122,6 +1128,58 @@ export class OpenCodeRuntime implements AgentRuntime {
|
|||||||
.getAvailableToolNames(request.knowledgeCapabilityToken)
|
.getAvailableToolNames(request.knowledgeCapabilityToken)
|
||||||
.map((toolName) => `${knowledgeMcpName}_${toolName}`)
|
.map((toolName) => `${knowledgeMcpName}_${toolName}`)
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
request.workMode === 'execute' &&
|
||||||
|
this.usesEmbeddedPermissionMediation() &&
|
||||||
|
this.options.knowledgeGateway?.getEndpoint() &&
|
||||||
|
this.options.mcpServers?.length
|
||||||
|
) {
|
||||||
|
customMcpToken = this.options.knowledgeGateway.grantCustomMcp(
|
||||||
|
request.requestId,
|
||||||
|
this.options.mcpServers,
|
||||||
|
signal
|
||||||
|
)
|
||||||
|
if (customMcpToken) {
|
||||||
|
const tools =
|
||||||
|
await this.options.knowledgeGateway.prepareCustomMcpTools(
|
||||||
|
customMcpToken,
|
||||||
|
signal
|
||||||
|
)
|
||||||
|
customMcpName = `goodbuddy-custom-${createHash('sha256')
|
||||||
|
.update(`${request.conversationId}\0${request.requestId}`)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 20)}`
|
||||||
|
const added = await client.mcp.add({
|
||||||
|
directory,
|
||||||
|
name: customMcpName,
|
||||||
|
config: {
|
||||||
|
type: 'remote',
|
||||||
|
url: this.options.knowledgeGateway.getEndpoint()!,
|
||||||
|
enabled: true,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${customMcpToken}`
|
||||||
|
},
|
||||||
|
oauth: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const addedStatus = added.data?.[customMcpName]
|
||||||
|
if (
|
||||||
|
added.error ||
|
||||||
|
!added.data ||
|
||||||
|
!addedStatus ||
|
||||||
|
addedStatus.status !== 'connected'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`OpenCode 自定义 MCP 连接失败(${addedStatus?.status ?? 'unknown'})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
knowledgeToolIds.push(
|
||||||
|
...tools.map(
|
||||||
|
(tool) => `${customMcpName}_${tool.name}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
const permission =
|
const permission =
|
||||||
request.workMode === 'execute'
|
request.workMode === 'execute'
|
||||||
? [
|
? [
|
||||||
@@ -1618,6 +1676,14 @@ export class OpenCodeRuntime implements AgentRuntime {
|
|||||||
.disconnect({ name: knowledgeMcpName, directory })
|
.disconnect({ name: knowledgeMcpName, directory })
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
if (customMcpName) {
|
||||||
|
await client.mcp
|
||||||
|
.disconnect({ name: customMcpName, directory })
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
if (customMcpToken) {
|
||||||
|
this.options.knowledgeGateway?.revoke(customMcpToken)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
|
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join, resolve } from 'node:path'
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import {
|
import {
|
||||||
@@ -26,12 +26,14 @@ import {
|
|||||||
} from '../capabilities/browser-profile-service'
|
} from '../capabilities/browser-profile-service'
|
||||||
import {
|
import {
|
||||||
CapabilityService,
|
CapabilityService,
|
||||||
type CapabilityCipher
|
type CapabilityCipher,
|
||||||
|
type ResolvedMcpServer
|
||||||
} from '../capabilities/capability-service'
|
} from '../capabilities/capability-service'
|
||||||
import {
|
import {
|
||||||
goodbuddyConfigToolByName,
|
goodbuddyConfigToolByName,
|
||||||
goodbuddyConfigTools
|
goodbuddyConfigTools
|
||||||
} from '../../shared/goodbuddy-config-tools'
|
} from '../../shared/goodbuddy-config-tools'
|
||||||
|
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||||
|
|
||||||
const enabled = process.env.GOODBUDDY_RUN_RUNTIME_E2E === '1'
|
const enabled = process.env.GOODBUDDY_RUN_RUNTIME_E2E === '1'
|
||||||
const apiKey =
|
const apiKey =
|
||||||
@@ -53,11 +55,14 @@ const protocol = modelProtocolSchema
|
|||||||
.parse(
|
.parse(
|
||||||
process.env.GOODBUDDY_E2E_PROTOCOL ?? 'anthropic-messages'
|
process.env.GOODBUDDY_E2E_PROTOCOL ?? 'anthropic-messages'
|
||||||
)
|
)
|
||||||
const portableRoot = join(
|
const portableRoot = process.env.GOODBUDDY_E2E_PACKAGED_ROOT
|
||||||
|
? resolve(process.env.GOODBUDDY_E2E_PACKAGED_ROOT)
|
||||||
|
: join(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
'dist',
|
'dist',
|
||||||
'GoodBuddy-0.1.0-win-x64-portable'
|
'harness-package-probe',
|
||||||
)
|
'win-unpacked'
|
||||||
|
)
|
||||||
|
|
||||||
async function collectText(
|
async function collectText(
|
||||||
events: AsyncGenerator<RuntimeEvent, void, void>
|
events: AsyncGenerator<RuntimeEvent, void, void>
|
||||||
@@ -71,6 +76,38 @@ async function collectText(
|
|||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function collectEvents(
|
||||||
|
events: AsyncGenerator<RuntimeEvent, void, void>
|
||||||
|
): Promise<RuntimeEvent[]> {
|
||||||
|
const collected: RuntimeEvent[] = []
|
||||||
|
for await (const event of events) {
|
||||||
|
collected.push(event)
|
||||||
|
}
|
||||||
|
return collected
|
||||||
|
}
|
||||||
|
|
||||||
|
function customMcpServer(
|
||||||
|
assignment: 'opencode' | 'continue'
|
||||||
|
): ResolvedMcpServer {
|
||||||
|
return {
|
||||||
|
id:
|
||||||
|
assignment === 'opencode'
|
||||||
|
? '00000000-0000-4000-8000-0000000000e1'
|
||||||
|
: '00000000-0000-4000-8000-0000000000e2',
|
||||||
|
name: 'Live Blueprint MCP',
|
||||||
|
description: 'Deterministic local Runtime E2E fixture',
|
||||||
|
enabled: true,
|
||||||
|
allowDynamicTools: false,
|
||||||
|
assignments: [assignment],
|
||||||
|
secretConfigured: false,
|
||||||
|
transport: 'stdio',
|
||||||
|
command: process.execPath,
|
||||||
|
args: [
|
||||||
|
resolve('tests', 'fixtures', 'web-3d-game-mcp.mjs')
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function textResult(value: unknown): ModelToolResult {
|
function textResult(value: unknown): ModelToolResult {
|
||||||
const text = JSON.stringify(value)
|
const text = JSON.stringify(value)
|
||||||
return {
|
return {
|
||||||
@@ -898,4 +935,176 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
|||||||
},
|
},
|
||||||
180_000
|
180_000
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it(
|
||||||
|
'calls a Main-brokered custom MCP through bundled OpenCode',
|
||||||
|
async () => {
|
||||||
|
const gateway = new KnowledgeMcpGateway({} as never)
|
||||||
|
await gateway.start()
|
||||||
|
const runtime = new AgentRuntimeController(
|
||||||
|
new OpenCodeRuntime({
|
||||||
|
embedded: true,
|
||||||
|
binaryPath: '',
|
||||||
|
bundledBinaryPath: join(
|
||||||
|
portableRoot,
|
||||||
|
'resources',
|
||||||
|
'runtimes',
|
||||||
|
'opencode',
|
||||||
|
'opencode.exe'
|
||||||
|
),
|
||||||
|
configPath: '',
|
||||||
|
defaultWorkspace: workspace,
|
||||||
|
modelProfile: {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: 'E2E model',
|
||||||
|
baseUrl,
|
||||||
|
modelName,
|
||||||
|
apiKey,
|
||||||
|
protocol,
|
||||||
|
authentication: 'api-key'
|
||||||
|
},
|
||||||
|
knowledgeGateway: gateway,
|
||||||
|
mcpServers: [customMcpServer('opencode')]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await collectEvents(
|
||||||
|
runtime.run(
|
||||||
|
{
|
||||||
|
requestId: crypto.randomUUID(),
|
||||||
|
conversationId: crypto.randomUUID(),
|
||||||
|
workMode: 'execute',
|
||||||
|
prompt:
|
||||||
|
'Use the assigned custom MCP tool to create a neon-ruins game blueprint with seed opencode-live and targetCount 5. Then reply with OPENCODE_MCP_E2E_OK and the blueprint title.'
|
||||||
|
},
|
||||||
|
new AbortController().signal,
|
||||||
|
async (request) =>
|
||||||
|
[
|
||||||
|
request.scopeKey,
|
||||||
|
request.title,
|
||||||
|
request.description,
|
||||||
|
request.toolName ?? ''
|
||||||
|
].some((value) =>
|
||||||
|
value.includes('create_game_blueprint')
|
||||||
|
)
|
||||||
|
? 'once'
|
||||||
|
: 'deny'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expect(events).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'tool',
|
||||||
|
name: expect.stringContaining(
|
||||||
|
'create_game_blueprint'
|
||||||
|
),
|
||||||
|
state: 'completed'
|
||||||
|
})
|
||||||
|
])
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
events
|
||||||
|
.flatMap((event) =>
|
||||||
|
event.type === 'text' ? [event.delta] : []
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
).toContain('OPENCODE_MCP_E2E_OK')
|
||||||
|
expect(
|
||||||
|
events
|
||||||
|
.flatMap((event) =>
|
||||||
|
event.type === 'text' ? [event.delta] : []
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
).toContain('Prism Relay')
|
||||||
|
} finally {
|
||||||
|
await runtime.dispose()
|
||||||
|
await gateway.dispose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
180_000
|
||||||
|
)
|
||||||
|
|
||||||
|
it(
|
||||||
|
'calls a Main-brokered custom MCP through bundled Continue',
|
||||||
|
async () => {
|
||||||
|
const gateway = new KnowledgeMcpGateway({} as never)
|
||||||
|
await gateway.start()
|
||||||
|
const runtime = new AgentRuntimeController(
|
||||||
|
new ContinueAgentRuntime({
|
||||||
|
binaryPath: '',
|
||||||
|
bundledBinaryPath: join(
|
||||||
|
portableRoot,
|
||||||
|
'resources',
|
||||||
|
'runtimes',
|
||||||
|
'continue',
|
||||||
|
'dist',
|
||||||
|
'cn.js'
|
||||||
|
),
|
||||||
|
configPath: '',
|
||||||
|
defaultWorkspace: workspace,
|
||||||
|
hostCacheRoot: join(workspace, '.continue-mcp-host'),
|
||||||
|
modelProfile: {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: 'E2E model',
|
||||||
|
baseUrl,
|
||||||
|
modelName,
|
||||||
|
apiKey,
|
||||||
|
protocol,
|
||||||
|
authentication: 'api-key'
|
||||||
|
},
|
||||||
|
knowledgeGateway: gateway,
|
||||||
|
mcpServers: [customMcpServer('continue')]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await collectEvents(
|
||||||
|
runtime.run(
|
||||||
|
{
|
||||||
|
requestId: crypto.randomUUID(),
|
||||||
|
conversationId: crypto.randomUUID(),
|
||||||
|
workMode: 'execute',
|
||||||
|
prompt:
|
||||||
|
'Use the assigned custom MCP tool to create a neon-ruins game blueprint with seed continue-live and targetCount 5. Then reply with CONTINUE_MCP_E2E_OK and the blueprint title.'
|
||||||
|
},
|
||||||
|
new AbortController().signal,
|
||||||
|
async (request) =>
|
||||||
|
[
|
||||||
|
request.scopeKey,
|
||||||
|
request.title,
|
||||||
|
request.description,
|
||||||
|
request.toolName ?? ''
|
||||||
|
].some((value) =>
|
||||||
|
value.includes('create_game_blueprint')
|
||||||
|
)
|
||||||
|
? 'once'
|
||||||
|
: 'deny'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expect(events).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'tool',
|
||||||
|
name: expect.stringContaining(
|
||||||
|
'create_game_blueprint'
|
||||||
|
),
|
||||||
|
state: 'completed'
|
||||||
|
})
|
||||||
|
])
|
||||||
|
)
|
||||||
|
const output = events
|
||||||
|
.flatMap((event) =>
|
||||||
|
event.type === 'text' ? [event.delta] : []
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
expect(output).toContain('CONTINUE_MCP_E2E_OK')
|
||||||
|
expect(output).toContain('Prism Relay')
|
||||||
|
} finally {
|
||||||
|
await runtime.dispose()
|
||||||
|
await gateway.dispose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
180_000
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
import {
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readFile,
|
||||||
|
readdir,
|
||||||
|
rm,
|
||||||
|
writeFile
|
||||||
|
} from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { RuntimeExtensionCatalogEntry } from '../../shared/runtime-extension-contracts'
|
||||||
|
import {
|
||||||
|
RuntimeExtensionStore,
|
||||||
|
type RuntimeExtensionStoreDependencies
|
||||||
|
} from './runtime-extension-store'
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = []
|
||||||
|
|
||||||
|
function catalogEntry(version = '1.0.0'): RuntimeExtensionCatalogEntry {
|
||||||
|
return {
|
||||||
|
id: 'test-extension',
|
||||||
|
package: {
|
||||||
|
name: '@goodbuddy/test-extension',
|
||||||
|
version
|
||||||
|
},
|
||||||
|
displayName: 'Test extension',
|
||||||
|
description: 'A deterministic extension store fixture.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultInstall(input: {
|
||||||
|
destinationDirectory: string
|
||||||
|
}): Promise<{ entrypoint: string; integrity: string }> {
|
||||||
|
const distribution = join(input.destinationDirectory, 'dist')
|
||||||
|
await mkdir(distribution, { recursive: true })
|
||||||
|
await writeFile(join(distribution, 'index.js'), 'export default {}')
|
||||||
|
return {
|
||||||
|
entrypoint: 'dist/index.js',
|
||||||
|
integrity: `sha512-${Buffer.from('verified').toString('base64')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixture(input: {
|
||||||
|
entries?: RuntimeExtensionCatalogEntry[]
|
||||||
|
install?: RuntimeExtensionStoreDependencies['install']
|
||||||
|
temporaryIds?: string[]
|
||||||
|
marketplaceEnabled?: boolean
|
||||||
|
} = {}): Promise<{
|
||||||
|
userDataPath: string
|
||||||
|
store: RuntimeExtensionStore
|
||||||
|
dependencies: RuntimeExtensionStoreDependencies
|
||||||
|
}> {
|
||||||
|
const userDataPath = await mkdtemp(
|
||||||
|
join(tmpdir(), 'goodbuddy-extension-store-')
|
||||||
|
)
|
||||||
|
temporaryDirectories.push(userDataPath)
|
||||||
|
const entries = input.entries ?? [catalogEntry()]
|
||||||
|
const temporaryIds = input.temporaryIds ?? ['install-one']
|
||||||
|
const dependencies: RuntimeExtensionStoreDependencies = {
|
||||||
|
catalog: {
|
||||||
|
list: vi.fn(async () => entries)
|
||||||
|
},
|
||||||
|
install: vi.fn(input.install ?? defaultInstall),
|
||||||
|
now: () => new Date('2026-08-16T00:00:00.000Z'),
|
||||||
|
temporaryId: () => {
|
||||||
|
const id = temporaryIds.shift()
|
||||||
|
if (!id) {
|
||||||
|
throw new Error('No fixture temporary ID remains')
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const store = new RuntimeExtensionStore(userDataPath, dependencies)
|
||||||
|
if (input.marketplaceEnabled ?? true) {
|
||||||
|
await store.apply({
|
||||||
|
type: 'set-marketplace-enabled',
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userDataPath,
|
||||||
|
dependencies,
|
||||||
|
store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories.splice(0).map((directory) =>
|
||||||
|
rm(directory, { recursive: true, force: true })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RuntimeExtensionStore', () => {
|
||||||
|
it('keeps a fresh marketplace disabled without loading the catalog', async () => {
|
||||||
|
const { store, dependencies } = await fixture({
|
||||||
|
marketplaceEnabled: false
|
||||||
|
})
|
||||||
|
const entry = catalogEntry()
|
||||||
|
|
||||||
|
await expect(store.getSnapshot()).resolves.toEqual({
|
||||||
|
marketplaceEnabled: false,
|
||||||
|
catalog: [],
|
||||||
|
installed: []
|
||||||
|
})
|
||||||
|
expect(dependencies.catalog.list).not.toHaveBeenCalled()
|
||||||
|
await expect(
|
||||||
|
store.apply({
|
||||||
|
type: 'install',
|
||||||
|
extensionId: entry.id,
|
||||||
|
package: entry.package
|
||||||
|
})
|
||||||
|
).rejects.toThrow('marketplace is disabled')
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.applyWithResult({
|
||||||
|
type: 'set-marketplace-enabled',
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
changed: true,
|
||||||
|
snapshot: { marketplaceEnabled: true }
|
||||||
|
})
|
||||||
|
await expect(store.getSnapshot()).resolves.toMatchObject({
|
||||||
|
marketplaceEnabled: true,
|
||||||
|
catalog: [entry]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the marketplace enabled when migrating installed version 1 state', async () => {
|
||||||
|
const { userDataPath, dependencies } = await fixture({
|
||||||
|
marketplaceEnabled: false
|
||||||
|
})
|
||||||
|
const entry = catalogEntry()
|
||||||
|
const extensionDirectory = join(
|
||||||
|
userDataPath,
|
||||||
|
'runtime-extensions',
|
||||||
|
'extensions',
|
||||||
|
entry.id
|
||||||
|
)
|
||||||
|
await mkdir(join(extensionDirectory, 'dist'), { recursive: true })
|
||||||
|
const entrypoint = join(extensionDirectory, 'dist', 'index.js')
|
||||||
|
await writeFile(entrypoint, 'export default {}')
|
||||||
|
await writeFile(
|
||||||
|
join(userDataPath, 'runtime-extensions', 'store.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
installed: [
|
||||||
|
{
|
||||||
|
id: entry.id,
|
||||||
|
package: entry.package,
|
||||||
|
entrypoint,
|
||||||
|
installedAt: '2026-08-16T00:00:00.000Z',
|
||||||
|
enabled: true,
|
||||||
|
configuration: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
const migrated = new RuntimeExtensionStore(
|
||||||
|
userDataPath,
|
||||||
|
dependencies
|
||||||
|
)
|
||||||
|
await expect(migrated.getSnapshot()).resolves.toMatchObject({
|
||||||
|
marketplaceEnabled: true,
|
||||||
|
installed: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: entry.id,
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
readFile(
|
||||||
|
join(userDataPath, 'runtime-extensions', 'store.json'),
|
||||||
|
'utf8'
|
||||||
|
).then((value) => JSON.parse(value) as unknown)
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
version: 2,
|
||||||
|
marketplaceEnabled: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('installs one exact, integrity-verified package directory', async () => {
|
||||||
|
const { userDataPath, store, dependencies } = await fixture()
|
||||||
|
const entry = catalogEntry()
|
||||||
|
|
||||||
|
const snapshot = await store.apply({
|
||||||
|
type: 'install',
|
||||||
|
extensionId: entry.id,
|
||||||
|
package: entry.package
|
||||||
|
})
|
||||||
|
|
||||||
|
const extensionDirectory = join(
|
||||||
|
userDataPath,
|
||||||
|
'runtime-extensions',
|
||||||
|
'extensions',
|
||||||
|
entry.id
|
||||||
|
)
|
||||||
|
expect(snapshot.installed).toEqual([
|
||||||
|
{
|
||||||
|
id: entry.id,
|
||||||
|
package: entry.package,
|
||||||
|
installedAt: '2026-08-16T00:00:00.000Z',
|
||||||
|
enabled: true,
|
||||||
|
configuration: {},
|
||||||
|
integrity: `sha512-${Buffer.from('verified').toString('base64')}`
|
||||||
|
}
|
||||||
|
])
|
||||||
|
await expect(store.getEnabledExtensions()).resolves.toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: entry.id,
|
||||||
|
entrypoint: join(extensionDirectory, 'dist', 'index.js')
|
||||||
|
})
|
||||||
|
])
|
||||||
|
await expect(
|
||||||
|
readFile(join(extensionDirectory, 'dist', 'index.js'), 'utf8')
|
||||||
|
).resolves.toBe('export default {}')
|
||||||
|
expect(dependencies.install).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
destinationDirectory: expect.stringMatching(
|
||||||
|
/\.staging[\\/]install-one$/u
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the marketplace without disabling installed plugins', async () => {
|
||||||
|
const { store, dependencies } = await fixture()
|
||||||
|
const entry = catalogEntry()
|
||||||
|
await store.apply({
|
||||||
|
type: 'install',
|
||||||
|
extensionId: entry.id,
|
||||||
|
package: entry.package
|
||||||
|
})
|
||||||
|
vi.mocked(dependencies.catalog.list).mockClear()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.apply({
|
||||||
|
type: 'set-marketplace-enabled',
|
||||||
|
enabled: false
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
marketplaceEnabled: false,
|
||||||
|
catalog: [],
|
||||||
|
installed: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: entry.id,
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
expect(dependencies.catalog.list).not.toHaveBeenCalled()
|
||||||
|
await expect(store.getEnabledExtensions()).resolves.toEqual([
|
||||||
|
expect.objectContaining({ id: entry.id })
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves an existing installation untouched when an upgrade fails', async () => {
|
||||||
|
const first = catalogEntry('1.0.0')
|
||||||
|
const second = catalogEntry('2.0.0')
|
||||||
|
const { store, dependencies } = await fixture({
|
||||||
|
entries: [first],
|
||||||
|
temporaryIds: ['install-one', 'install-two']
|
||||||
|
})
|
||||||
|
await store.apply({
|
||||||
|
type: 'install',
|
||||||
|
extensionId: first.id,
|
||||||
|
package: first.package
|
||||||
|
})
|
||||||
|
await store.apply({
|
||||||
|
type: 'configure',
|
||||||
|
extensionId: first.id,
|
||||||
|
configuration: { nested: { value: 1 } }
|
||||||
|
})
|
||||||
|
await store.apply({
|
||||||
|
type: 'set-enabled',
|
||||||
|
extensionId: first.id,
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
vi.mocked(dependencies.catalog.list).mockResolvedValue([second])
|
||||||
|
vi.mocked(dependencies.install).mockRejectedValueOnce(
|
||||||
|
new Error('Entrypoint contract mismatch')
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.apply({
|
||||||
|
type: 'install',
|
||||||
|
extensionId: second.id,
|
||||||
|
package: second.package
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Entrypoint contract mismatch')
|
||||||
|
|
||||||
|
expect((await store.getSnapshot()).installed[0]).toMatchObject({
|
||||||
|
package: first.package,
|
||||||
|
enabled: true,
|
||||||
|
configuration: { nested: { value: 1 } }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects installer entrypoints outside the managed package', async () => {
|
||||||
|
const entry = catalogEntry()
|
||||||
|
const fixtureValue = await fixture({
|
||||||
|
entries: [entry],
|
||||||
|
install: async () => ({ entrypoint: '../outside.js' })
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
fixtureValue.store.apply({
|
||||||
|
type: 'install',
|
||||||
|
extensionId: entry.id,
|
||||||
|
package: entry.package
|
||||||
|
})
|
||||||
|
).rejects.toThrow('invalid entrypoint')
|
||||||
|
expect(
|
||||||
|
(await fixtureValue.store.getSnapshot()).installed
|
||||||
|
).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('configures, launches, and disables startup failures', async () => {
|
||||||
|
const { store } = await fixture()
|
||||||
|
const entry = catalogEntry()
|
||||||
|
await store.apply({
|
||||||
|
type: 'install',
|
||||||
|
extensionId: entry.id,
|
||||||
|
package: entry.package
|
||||||
|
})
|
||||||
|
await store.apply({
|
||||||
|
type: 'configure',
|
||||||
|
extensionId: entry.id,
|
||||||
|
configuration: {
|
||||||
|
endpoint: 'https://example.com',
|
||||||
|
options: { retries: 2, tags: ['one', 'two'] }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await expect(store.getEnabledExtensions()).resolves.toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: entry.id,
|
||||||
|
configuration: {
|
||||||
|
endpoint: 'https://example.com',
|
||||||
|
options: { retries: 2, tags: ['one', 'two'] }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
await store.markStartupFailed([entry.id, 'not-installed'])
|
||||||
|
expect((await store.getSnapshot()).installed[0]).toMatchObject({
|
||||||
|
enabled: false,
|
||||||
|
lastError: 'startup-failed'
|
||||||
|
})
|
||||||
|
await expect(store.getEnabledExtensions()).resolves.toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports semantic no-op mutations without refreshing the catalog', async () => {
|
||||||
|
const { store, dependencies } = await fixture()
|
||||||
|
const entry = catalogEntry()
|
||||||
|
await store.apply({
|
||||||
|
type: 'install',
|
||||||
|
extensionId: entry.id,
|
||||||
|
package: entry.package
|
||||||
|
})
|
||||||
|
await store.apply({
|
||||||
|
type: 'configure',
|
||||||
|
extensionId: entry.id,
|
||||||
|
configuration: { first: 1, second: 2 }
|
||||||
|
})
|
||||||
|
vi.mocked(dependencies.catalog.list).mockClear()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.applyWithResult({
|
||||||
|
type: 'configure',
|
||||||
|
extensionId: entry.id,
|
||||||
|
configuration: { second: 2, first: 1 }
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ changed: false })
|
||||||
|
await expect(
|
||||||
|
store.applyWithResult({
|
||||||
|
type: 'set-enabled',
|
||||||
|
extensionId: entry.id,
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ changed: false })
|
||||||
|
expect(dependencies.catalog.list).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps installed extensions manageable while the catalog is offline', async () => {
|
||||||
|
const { store, dependencies } = await fixture()
|
||||||
|
const entry = catalogEntry()
|
||||||
|
await store.apply({
|
||||||
|
type: 'install',
|
||||||
|
extensionId: entry.id,
|
||||||
|
package: entry.package
|
||||||
|
})
|
||||||
|
vi.mocked(dependencies.catalog.list).mockRejectedValue(
|
||||||
|
new Error('npm registry unavailable')
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(store.getSnapshot()).resolves.toMatchObject({
|
||||||
|
catalog: [],
|
||||||
|
catalogError: 'npm registry unavailable',
|
||||||
|
installed: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: entry.id,
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
vi.mocked(dependencies.catalog.list).mockClear()
|
||||||
|
await expect(
|
||||||
|
store.apply({
|
||||||
|
type: 'set-enabled',
|
||||||
|
extensionId: entry.id,
|
||||||
|
enabled: false
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
catalog: [],
|
||||||
|
catalogError: 'npm registry unavailable',
|
||||||
|
installed: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: entry.id,
|
||||||
|
enabled: false
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
expect(dependencies.catalog.list).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes mutations and removes only its managed extension directory', async () => {
|
||||||
|
const { userDataPath, store } = await fixture()
|
||||||
|
const entry = catalogEntry()
|
||||||
|
const outsidePath = join(userDataPath, 'outside.txt')
|
||||||
|
await writeFile(outsidePath, 'preserve me')
|
||||||
|
await store.apply({
|
||||||
|
type: 'install',
|
||||||
|
extensionId: entry.id,
|
||||||
|
package: entry.package
|
||||||
|
})
|
||||||
|
|
||||||
|
const configured = store.apply({
|
||||||
|
type: 'configure',
|
||||||
|
extensionId: entry.id,
|
||||||
|
configuration: { order: 1 }
|
||||||
|
})
|
||||||
|
const enabled = store.apply({
|
||||||
|
type: 'set-enabled',
|
||||||
|
extensionId: entry.id,
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
await Promise.all([configured, enabled])
|
||||||
|
expect((await store.getSnapshot()).installed[0]).toMatchObject({
|
||||||
|
enabled: true,
|
||||||
|
configuration: { order: 1 }
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.apply({ type: 'remove', extensionId: entry.id })
|
||||||
|
await expect(readFile(outsidePath, 'utf8')).resolves.toBe('preserve me')
|
||||||
|
expect((await store.getSnapshot()).installed).toEqual([])
|
||||||
|
await expect(
|
||||||
|
readdir(join(userDataPath, 'runtime-extensions', 'extensions'))
|
||||||
|
).resolves.toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,689 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import {
|
||||||
|
lstat,
|
||||||
|
mkdir,
|
||||||
|
readFile,
|
||||||
|
readdir,
|
||||||
|
realpath,
|
||||||
|
rename,
|
||||||
|
rmdir,
|
||||||
|
unlink
|
||||||
|
} from 'node:fs/promises'
|
||||||
|
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||||
|
import { isDeepStrictEqual } from 'node:util'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import {
|
||||||
|
runtimeExtensionActionSchema,
|
||||||
|
runtimeExtensionCatalogEntrySchema,
|
||||||
|
runtimeExtensionIdSchema,
|
||||||
|
runtimeExtensionInstalledStateSchema,
|
||||||
|
runtimeExtensionStartupFailureCode,
|
||||||
|
type RuntimeExtensionAction,
|
||||||
|
type RuntimeExtensionCatalogEntry,
|
||||||
|
type RuntimeExtensionConfiguration,
|
||||||
|
type RuntimeExtensionExactPackage,
|
||||||
|
type RuntimeExtensionInstalledState,
|
||||||
|
type RuntimeExtensionMarketplaceInstalledState,
|
||||||
|
type RuntimeExtensionMarketplaceSnapshot
|
||||||
|
} from '../../shared/runtime-extension-contracts'
|
||||||
|
import {
|
||||||
|
isMissingFileError,
|
||||||
|
writeJsonFileAtomically
|
||||||
|
} from '../settings-file-utils'
|
||||||
|
|
||||||
|
const managedDirectoryName = 'runtime-extensions'
|
||||||
|
const stateFileName = 'store.json'
|
||||||
|
|
||||||
|
const version1StoredStateSchema = z
|
||||||
|
.object({
|
||||||
|
version: z.literal(1),
|
||||||
|
installed: z.array(runtimeExtensionInstalledStateSchema)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
const storedStateSchema = z
|
||||||
|
.object({
|
||||||
|
version: z.literal(2),
|
||||||
|
marketplaceEnabled: z.boolean(),
|
||||||
|
installed: z.array(runtimeExtensionInstalledStateSchema)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
const storedStateFileSchema = z.union([
|
||||||
|
storedStateSchema,
|
||||||
|
version1StoredStateSchema
|
||||||
|
])
|
||||||
|
|
||||||
|
type StoredState = z.infer<typeof storedStateSchema>
|
||||||
|
|
||||||
|
export interface RuntimeExtensionCatalog {
|
||||||
|
list(): Promise<readonly RuntimeExtensionCatalogEntry[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeExtensionStoreDependencies {
|
||||||
|
catalog: RuntimeExtensionCatalog
|
||||||
|
install(input: {
|
||||||
|
entry: RuntimeExtensionCatalogEntry
|
||||||
|
destinationDirectory: string
|
||||||
|
}): Promise<{
|
||||||
|
entrypoint: string
|
||||||
|
integrity?: string
|
||||||
|
}>
|
||||||
|
now?: () => Date
|
||||||
|
temporaryId?: () => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnabledRuntimeExtension {
|
||||||
|
id: string
|
||||||
|
entrypoint: string
|
||||||
|
configuration: RuntimeExtensionConfiguration
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuntimeExtensionApplyResult = {
|
||||||
|
snapshot: RuntimeExtensionMarketplaceSnapshot
|
||||||
|
changed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyState(): StoredState {
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
marketplaceEnabled: false,
|
||||||
|
installed: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareIds(
|
||||||
|
left: { id: string },
|
||||||
|
right: { id: string }
|
||||||
|
): number {
|
||||||
|
return left.id.localeCompare(right.id, 'en')
|
||||||
|
}
|
||||||
|
|
||||||
|
function packagesEqual(
|
||||||
|
left: RuntimeExtensionExactPackage,
|
||||||
|
right: RuntimeExtensionExactPackage
|
||||||
|
): boolean {
|
||||||
|
return left.name === right.name && left.version === right.version
|
||||||
|
}
|
||||||
|
|
||||||
|
function marketplaceInstalledState(
|
||||||
|
extension: RuntimeExtensionInstalledState
|
||||||
|
): RuntimeExtensionMarketplaceInstalledState {
|
||||||
|
return {
|
||||||
|
id: extension.id,
|
||||||
|
package: extension.package,
|
||||||
|
installedAt: extension.installedAt,
|
||||||
|
enabled: extension.enabled,
|
||||||
|
configuration: extension.configuration,
|
||||||
|
...(extension.integrity
|
||||||
|
? { integrity: extension.integrity }
|
||||||
|
: {}),
|
||||||
|
...(extension.lastError
|
||||||
|
? { lastError: extension.lastError }
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RuntimeExtensionStore {
|
||||||
|
readonly managedRoot: string
|
||||||
|
|
||||||
|
private readonly statePath: string
|
||||||
|
private state?: StoredState
|
||||||
|
private stateLoad?: Promise<StoredState>
|
||||||
|
private canonicalRoot?: string
|
||||||
|
private mutationQueue: Promise<void> = Promise.resolve()
|
||||||
|
private catalog: RuntimeExtensionCatalogEntry[] = []
|
||||||
|
private catalogError?: string
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
userDataPath: string,
|
||||||
|
private readonly dependencies: RuntimeExtensionStoreDependencies
|
||||||
|
) {
|
||||||
|
if (!isAbsolute(userDataPath)) {
|
||||||
|
throw new Error('GoodBuddy userData path must be absolute')
|
||||||
|
}
|
||||||
|
this.managedRoot = resolve(userDataPath, managedDirectoryName)
|
||||||
|
this.statePath = join(this.managedRoot, stateFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSnapshot(): Promise<RuntimeExtensionMarketplaceSnapshot> {
|
||||||
|
const state = await this.load()
|
||||||
|
if (!state.marketplaceEnabled) {
|
||||||
|
this.catalog = []
|
||||||
|
this.catalogError = undefined
|
||||||
|
return this.marketplaceSnapshot(state)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.loadCatalog()
|
||||||
|
} catch (error) {
|
||||||
|
this.catalog = []
|
||||||
|
this.catalogError =
|
||||||
|
error instanceof Error && error.message.trim()
|
||||||
|
? error.message.trim().slice(0, 1_000)
|
||||||
|
: 'Extension catalog is unavailable.'
|
||||||
|
}
|
||||||
|
return this.marketplaceSnapshot(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async apply(
|
||||||
|
action: RuntimeExtensionAction
|
||||||
|
): Promise<RuntimeExtensionMarketplaceSnapshot> {
|
||||||
|
return (await this.applyWithResult(action)).snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyWithResult(
|
||||||
|
action: RuntimeExtensionAction
|
||||||
|
): Promise<RuntimeExtensionApplyResult> {
|
||||||
|
const parsed = runtimeExtensionActionSchema.parse(action)
|
||||||
|
const changed = await this.serialize(async () => {
|
||||||
|
switch (parsed.type) {
|
||||||
|
case 'set-marketplace-enabled':
|
||||||
|
return this.setMarketplaceEnabled(parsed.enabled)
|
||||||
|
case 'install':
|
||||||
|
await this.install(parsed.extensionId, parsed.package)
|
||||||
|
return true
|
||||||
|
case 'set-enabled':
|
||||||
|
return this.setEnabled(parsed.extensionId, parsed.enabled)
|
||||||
|
case 'remove':
|
||||||
|
await this.remove(parsed.extensionId)
|
||||||
|
return true
|
||||||
|
case 'configure':
|
||||||
|
return this.configure(
|
||||||
|
parsed.extensionId,
|
||||||
|
parsed.configuration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
snapshot: this.marketplaceSnapshot(await this.load()),
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEnabledExtensions(): Promise<EnabledRuntimeExtension[]> {
|
||||||
|
const state = await this.load()
|
||||||
|
return state.installed
|
||||||
|
.filter((extension) => extension.enabled)
|
||||||
|
.sort(compareIds)
|
||||||
|
.map(({ id, entrypoint, configuration }) => ({
|
||||||
|
id,
|
||||||
|
entrypoint,
|
||||||
|
configuration
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
markStartupFailed(ids: readonly string[]): Promise<void> {
|
||||||
|
const parsedIds = z.array(runtimeExtensionIdSchema).parse(ids)
|
||||||
|
return this.serialize(async () => {
|
||||||
|
const failed = new Set(parsedIds)
|
||||||
|
const state = await this.load()
|
||||||
|
const installed = state.installed.map((extension) =>
|
||||||
|
failed.has(extension.id)
|
||||||
|
? {
|
||||||
|
...extension,
|
||||||
|
enabled: false,
|
||||||
|
lastError: runtimeExtensionStartupFailureCode
|
||||||
|
}
|
||||||
|
: extension
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
installed.some(
|
||||||
|
(extension, index) => extension !== state.installed[index]
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await this.persistAndSet({ ...state, installed })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private serialize<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
const result = this.mutationQueue.then(operation)
|
||||||
|
this.mutationQueue = result.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): Promise<StoredState> {
|
||||||
|
if (this.state) {
|
||||||
|
return Promise.resolve(this.state)
|
||||||
|
}
|
||||||
|
if (!this.stateLoad) {
|
||||||
|
this.stateLoad = this.readState().finally(() => {
|
||||||
|
this.stateLoad = undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return this.stateLoad
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readState(): Promise<StoredState> {
|
||||||
|
await this.initialize()
|
||||||
|
try {
|
||||||
|
const status = await lstat(this.statePath)
|
||||||
|
if (
|
||||||
|
!status.isFile() ||
|
||||||
|
status.isSymbolicLink() ||
|
||||||
|
status.nlink > 1
|
||||||
|
) {
|
||||||
|
throw new Error('Extension store state must be a regular file')
|
||||||
|
}
|
||||||
|
await this.assertExistingPathContained(this.statePath)
|
||||||
|
const stored = storedStateFileSchema.parse(
|
||||||
|
JSON.parse(await readFile(this.statePath, 'utf8')) as unknown
|
||||||
|
)
|
||||||
|
const parsed: StoredState =
|
||||||
|
stored.version === 1
|
||||||
|
? {
|
||||||
|
version: 2,
|
||||||
|
marketplaceEnabled: stored.installed.length > 0,
|
||||||
|
installed: stored.installed
|
||||||
|
}
|
||||||
|
: stored
|
||||||
|
for (const extension of parsed.installed) {
|
||||||
|
this.assertExtensionEntrypoint(extension)
|
||||||
|
}
|
||||||
|
if (stored.version === 1) {
|
||||||
|
await this.persist(parsed)
|
||||||
|
}
|
||||||
|
this.state = parsed
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMissingFileError(error)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
this.state = emptyState()
|
||||||
|
await this.persist(this.state)
|
||||||
|
}
|
||||||
|
return this.state
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initialize(): Promise<void> {
|
||||||
|
await mkdir(this.managedRoot, { recursive: true, mode: 0o700 })
|
||||||
|
const status = await lstat(this.managedRoot)
|
||||||
|
if (!status.isDirectory() || status.isSymbolicLink()) {
|
||||||
|
throw new Error('Extension managed root must be a real directory')
|
||||||
|
}
|
||||||
|
this.canonicalRoot = await realpath(this.managedRoot)
|
||||||
|
await this.createManagedDirectory('extensions')
|
||||||
|
await this.createManagedDirectory('.staging')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadCatalog(): Promise<RuntimeExtensionCatalogEntry[]> {
|
||||||
|
const catalog = (await this.dependencies.catalog.list()).map((entry) =>
|
||||||
|
runtimeExtensionCatalogEntrySchema.parse(entry)
|
||||||
|
)
|
||||||
|
const ids = new Set<string>()
|
||||||
|
for (const entry of catalog) {
|
||||||
|
if (ids.has(entry.id)) {
|
||||||
|
throw new Error(`Duplicate extension catalog ID: ${entry.id}`)
|
||||||
|
}
|
||||||
|
ids.add(entry.id)
|
||||||
|
}
|
||||||
|
this.catalog = catalog.sort(compareIds)
|
||||||
|
this.catalogError = undefined
|
||||||
|
return this.catalog
|
||||||
|
}
|
||||||
|
|
||||||
|
private async install(
|
||||||
|
extensionId: string,
|
||||||
|
requestedPackage: RuntimeExtensionExactPackage
|
||||||
|
): Promise<void> {
|
||||||
|
const state = await this.load()
|
||||||
|
if (!state.marketplaceEnabled) {
|
||||||
|
throw new Error('The DSH plugin marketplace is disabled')
|
||||||
|
}
|
||||||
|
const catalog = await this.loadCatalog()
|
||||||
|
const entry = catalog.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.id === extensionId &&
|
||||||
|
packagesEqual(candidate.package, requestedPackage)
|
||||||
|
)
|
||||||
|
if (!entry) {
|
||||||
|
throw new Error('The exact extension package is not in the catalog')
|
||||||
|
}
|
||||||
|
|
||||||
|
const temporaryId =
|
||||||
|
this.dependencies.temporaryId?.() ?? randomUUID()
|
||||||
|
runtimeExtensionIdSchema.parse(temporaryId)
|
||||||
|
const stagedDirectory = await this.createFreshManagedDirectory(
|
||||||
|
'.staging',
|
||||||
|
temporaryId
|
||||||
|
)
|
||||||
|
const backupDirectory = this.managedPath(
|
||||||
|
'.staging',
|
||||||
|
`${temporaryId}-previous`
|
||||||
|
)
|
||||||
|
const finalDirectory = this.extensionDirectory(extensionId)
|
||||||
|
let previousMoved = false
|
||||||
|
let stagedMoved = false
|
||||||
|
try {
|
||||||
|
const installedPackage = await this.dependencies.install({
|
||||||
|
entry,
|
||||||
|
destinationDirectory: stagedDirectory
|
||||||
|
})
|
||||||
|
await this.resolveEntrypoint(
|
||||||
|
stagedDirectory,
|
||||||
|
installedPackage.entrypoint
|
||||||
|
)
|
||||||
|
|
||||||
|
if (await this.pathExists(finalDirectory)) {
|
||||||
|
await rename(finalDirectory, backupDirectory)
|
||||||
|
previousMoved = true
|
||||||
|
}
|
||||||
|
await rename(stagedDirectory, finalDirectory)
|
||||||
|
stagedMoved = true
|
||||||
|
const entrypoint = resolve(
|
||||||
|
finalDirectory,
|
||||||
|
installedPackage.entrypoint
|
||||||
|
)
|
||||||
|
const existing = state.installed.find(
|
||||||
|
(extension) => extension.id === extensionId
|
||||||
|
)
|
||||||
|
const installed: RuntimeExtensionInstalledState = {
|
||||||
|
id: extensionId,
|
||||||
|
package: entry.package,
|
||||||
|
entrypoint,
|
||||||
|
installedAt: (
|
||||||
|
this.dependencies.now?.() ?? new Date()
|
||||||
|
).toISOString(),
|
||||||
|
enabled: existing?.enabled ?? true,
|
||||||
|
configuration: existing?.configuration ?? {},
|
||||||
|
...(installedPackage.integrity
|
||||||
|
? { integrity: installedPackage.integrity }
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
await this.persistAndSet(
|
||||||
|
this.replaceInstalled(state, installed)
|
||||||
|
)
|
||||||
|
if (previousMoved) {
|
||||||
|
await this.removeManagedTree(backupDirectory).catch(() => undefined)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (stagedMoved) {
|
||||||
|
await this.removeManagedTree(finalDirectory)
|
||||||
|
}
|
||||||
|
if (previousMoved) {
|
||||||
|
await rename(backupDirectory, finalDirectory)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
await this.removeManagedTree(stagedDirectory).catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setEnabled(
|
||||||
|
extensionId: string,
|
||||||
|
enabled: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
const state = await this.load()
|
||||||
|
const extension = this.requireInstalled(state, extensionId)
|
||||||
|
if (
|
||||||
|
extension.enabled === enabled &&
|
||||||
|
(!enabled || !extension.lastError)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const updated = {
|
||||||
|
...extension,
|
||||||
|
enabled,
|
||||||
|
...(enabled ? { lastError: undefined } : {})
|
||||||
|
}
|
||||||
|
await this.persistAndSet(this.replaceInstalled(state, updated))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setMarketplaceEnabled(
|
||||||
|
enabled: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
const state = await this.load()
|
||||||
|
if (state.marketplaceEnabled === enabled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await this.persistAndSet({
|
||||||
|
...state,
|
||||||
|
marketplaceEnabled: enabled
|
||||||
|
})
|
||||||
|
if (!enabled) {
|
||||||
|
this.catalog = []
|
||||||
|
this.catalogError = undefined
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async configure(
|
||||||
|
extensionId: string,
|
||||||
|
configuration: RuntimeExtensionConfiguration
|
||||||
|
): Promise<boolean> {
|
||||||
|
const state = await this.load()
|
||||||
|
const extension = this.requireInstalled(state, extensionId)
|
||||||
|
if (isDeepStrictEqual(extension.configuration, configuration)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await this.persistAndSet(
|
||||||
|
this.replaceInstalled(state, { ...extension, configuration })
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private marketplaceSnapshot(
|
||||||
|
state: StoredState
|
||||||
|
): RuntimeExtensionMarketplaceSnapshot {
|
||||||
|
return {
|
||||||
|
marketplaceEnabled: state.marketplaceEnabled,
|
||||||
|
catalog: this.catalog,
|
||||||
|
installed: [...state.installed]
|
||||||
|
.sort(compareIds)
|
||||||
|
.map(marketplaceInstalledState),
|
||||||
|
...(this.catalogError
|
||||||
|
? { catalogError: this.catalogError }
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async remove(extensionId: string): Promise<void> {
|
||||||
|
const state = await this.load()
|
||||||
|
this.requireInstalled(state, extensionId)
|
||||||
|
const finalDirectory = this.extensionDirectory(extensionId)
|
||||||
|
const trashDirectory = this.managedPath(
|
||||||
|
'.staging',
|
||||||
|
`${randomUUID()}-removed`
|
||||||
|
)
|
||||||
|
let moved = false
|
||||||
|
if (await this.pathExists(finalDirectory)) {
|
||||||
|
await rename(finalDirectory, trashDirectory)
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.persistAndSet({
|
||||||
|
...state,
|
||||||
|
installed: state.installed.filter(
|
||||||
|
(extension) => extension.id !== extensionId
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (moved) {
|
||||||
|
await rename(trashDirectory, finalDirectory)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (moved) {
|
||||||
|
await this.removeManagedTree(trashDirectory).catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireInstalled(
|
||||||
|
state: StoredState,
|
||||||
|
extensionId: string
|
||||||
|
): RuntimeExtensionInstalledState {
|
||||||
|
const extension = state.installed.find(
|
||||||
|
(candidate) => candidate.id === extensionId
|
||||||
|
)
|
||||||
|
if (!extension) {
|
||||||
|
throw new Error(`Extension is not installed: ${extensionId}`)
|
||||||
|
}
|
||||||
|
return extension
|
||||||
|
}
|
||||||
|
|
||||||
|
private replaceInstalled(
|
||||||
|
state: StoredState,
|
||||||
|
extension: RuntimeExtensionInstalledState
|
||||||
|
): StoredState {
|
||||||
|
return storedStateSchema.parse({
|
||||||
|
...state,
|
||||||
|
installed: state.installed
|
||||||
|
.filter((candidate) => candidate.id !== extension.id)
|
||||||
|
.concat(extension)
|
||||||
|
.sort(compareIds)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistAndSet(state: StoredState): Promise<void> {
|
||||||
|
await this.persist(state)
|
||||||
|
this.state = state
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(state: StoredState): Promise<void> {
|
||||||
|
return writeJsonFileAtomically(
|
||||||
|
this.statePath,
|
||||||
|
storedStateSchema.parse(state)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertExtensionEntrypoint(
|
||||||
|
extension: RuntimeExtensionInstalledState
|
||||||
|
): void {
|
||||||
|
if (!isAbsolute(extension.entrypoint)) {
|
||||||
|
throw new Error('Installed extension entrypoint must be absolute')
|
||||||
|
}
|
||||||
|
const directory = this.extensionDirectory(extension.id)
|
||||||
|
this.assertContained(directory, extension.entrypoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private extensionDirectory(extensionId: string): string {
|
||||||
|
runtimeExtensionIdSchema.parse(extensionId)
|
||||||
|
return this.managedPath('extensions', extensionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private managedPath(...segments: string[]): string {
|
||||||
|
const path = resolve(this.managedRoot, ...segments)
|
||||||
|
this.assertContained(this.managedRoot, path)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertContained(root: string, path: string): void {
|
||||||
|
const relativePath = relative(root, path)
|
||||||
|
if (
|
||||||
|
relativePath === '' ||
|
||||||
|
(!relativePath.startsWith(`..${sep}`) &&
|
||||||
|
relativePath !== '..' &&
|
||||||
|
!isAbsolute(relativePath))
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error('Extension path escapes the managed root')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertExistingPathContained(path: string): Promise<void> {
|
||||||
|
this.assertContained(this.managedRoot, path)
|
||||||
|
const root = this.canonicalRoot ?? (await realpath(this.managedRoot))
|
||||||
|
this.assertContained(root, await realpath(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createManagedDirectory(
|
||||||
|
...segments: string[]
|
||||||
|
): Promise<string> {
|
||||||
|
let directory = this.managedRoot
|
||||||
|
await this.assertExistingPathContained(directory)
|
||||||
|
for (const segment of segments) {
|
||||||
|
directory = join(directory, segment)
|
||||||
|
this.assertContained(this.managedRoot, directory)
|
||||||
|
await mkdir(directory, { recursive: true, mode: 0o700 })
|
||||||
|
const status = await lstat(directory)
|
||||||
|
if (!status.isDirectory() || status.isSymbolicLink()) {
|
||||||
|
throw new Error('Extension managed path must be a real directory')
|
||||||
|
}
|
||||||
|
await this.assertExistingPathContained(directory)
|
||||||
|
}
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createFreshManagedDirectory(
|
||||||
|
...segments: string[]
|
||||||
|
): Promise<string> {
|
||||||
|
const leaf = segments.at(-1)
|
||||||
|
if (!leaf) {
|
||||||
|
throw new Error('A managed directory name is required')
|
||||||
|
}
|
||||||
|
const parent = await this.createManagedDirectory(...segments.slice(0, -1))
|
||||||
|
const directory = join(parent, leaf)
|
||||||
|
this.assertContained(this.managedRoot, directory)
|
||||||
|
await mkdir(directory, { mode: 0o700 })
|
||||||
|
await this.assertExistingPathContained(directory)
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pathExists(path: string): Promise<boolean> {
|
||||||
|
this.assertContained(this.managedRoot, path)
|
||||||
|
try {
|
||||||
|
await lstat(path)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingFileError(error)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveEntrypoint(
|
||||||
|
root: string,
|
||||||
|
relativeEntrypoint: string
|
||||||
|
): Promise<string> {
|
||||||
|
if (
|
||||||
|
!relativeEntrypoint ||
|
||||||
|
relativeEntrypoint.includes('\\') ||
|
||||||
|
relativeEntrypoint.startsWith('/') ||
|
||||||
|
/^[A-Za-z]:/u.test(relativeEntrypoint) ||
|
||||||
|
relativeEntrypoint
|
||||||
|
.split('/')
|
||||||
|
.some((part) => part === '' || part === '.' || part === '..')
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Extension installer returned an invalid entrypoint'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const entrypoint = resolve(root, relativeEntrypoint)
|
||||||
|
this.assertContained(root, entrypoint)
|
||||||
|
const [canonicalRoot, canonicalEntrypoint] = await Promise.all([
|
||||||
|
realpath(root),
|
||||||
|
realpath(entrypoint)
|
||||||
|
])
|
||||||
|
this.assertContained(canonicalRoot, canonicalEntrypoint)
|
||||||
|
const status = await lstat(canonicalEntrypoint)
|
||||||
|
if (!status.isFile()) {
|
||||||
|
throw new Error('Extension entrypoint is not a regular file')
|
||||||
|
}
|
||||||
|
return canonicalEntrypoint
|
||||||
|
}
|
||||||
|
|
||||||
|
private async removeManagedTree(path: string): Promise<void> {
|
||||||
|
this.assertContained(this.managedRoot, path)
|
||||||
|
let status
|
||||||
|
try {
|
||||||
|
status = await lstat(path)
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingFileError(error)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (status.isDirectory() && !status.isSymbolicLink()) {
|
||||||
|
for (const entry of await readdir(path)) {
|
||||||
|
await this.removeManagedTree(join(path, entry))
|
||||||
|
}
|
||||||
|
await rmdir(path)
|
||||||
|
} else {
|
||||||
|
await unlink(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -630,7 +630,7 @@ describe('CapabilityService', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allows Harness MCP assignment and rejects unsupported Agent Runtimes', async () => {
|
it('allows MCP assignment to every supported runtime', async () => {
|
||||||
const { service } = await createService()
|
const { service } = await createService()
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -661,16 +661,28 @@ describe('CapabilityService', () => {
|
|||||||
description: '',
|
description: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowDynamicTools: false,
|
allowDynamicTools: false,
|
||||||
assignments: ['opencode'],
|
assignments: ['opencode', 'continue'],
|
||||||
secret: { action: 'keep' },
|
secret: { action: 'keep' },
|
||||||
transport: 'stdio',
|
transport: 'stdio',
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['server.js']
|
args: ['server.js']
|
||||||
})
|
})
|
||||||
).rejects.toThrow('只能分配给直连模型或 DeepSeek Harness')
|
).resolves.toMatchObject({
|
||||||
|
mcpServers: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
assignments: ['opencode', 'continue']
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
service.getResolvedMcpServers('opencode')
|
||||||
|
).resolves.toHaveLength(1)
|
||||||
|
await expect(
|
||||||
|
service.getResolvedMcpServers('continue')
|
||||||
|
).resolves.toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('migrates legacy OpenCode MCP assignments to the direct model', async () => {
|
it('preserves stored OpenCode MCP assignments', async () => {
|
||||||
const { filePath, builtinRoot, importedRoot } = await createService()
|
const { filePath, builtinRoot, importedRoot } = await createService()
|
||||||
await writeFile(
|
await writeFile(
|
||||||
filePath,
|
filePath,
|
||||||
@@ -701,14 +713,14 @@ describe('CapabilityService', () => {
|
|||||||
|
|
||||||
await expect(service.getSnapshot()).resolves.toMatchObject({
|
await expect(service.getSnapshot()).resolves.toMatchObject({
|
||||||
mcpServers: [
|
mcpServers: [
|
||||||
expect.objectContaining({ assignments: ['model'] })
|
expect.objectContaining({ assignments: ['opencode'] })
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
expect(await readFile(filePath, 'utf8')).toContain(
|
expect(await readFile(filePath, 'utf8')).toContain(
|
||||||
'"assignments": [\n "model"'
|
'"assignments": [\n "opencode"'
|
||||||
)
|
)
|
||||||
await expect(service.getResolvedMcpServers('opencode')).resolves.toEqual([])
|
await expect(service.getResolvedMcpServers('opencode')).resolves.toHaveLength(1)
|
||||||
await expect(service.getResolvedMcpServers('model')).resolves.toHaveLength(1)
|
await expect(service.getResolvedMcpServers('model')).resolves.toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('migrates v1 to v3 without losing skills, MCP configuration, or encrypted secrets', async () => {
|
it('migrates v1 to v3 without losing skills, MCP configuration, or encrypted secrets', async () => {
|
||||||
|
|||||||
@@ -789,23 +789,9 @@ export class CapabilityService {
|
|||||||
shouldPersist = true
|
shouldPersist = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const migrateMcpAssignments = loaded.mcpServers.some((server) =>
|
this.state = storedCapabilitiesSchema.parse(loaded)
|
||||||
server.assignments.includes('opencode')
|
|
||||||
)
|
|
||||||
const migrated = migrateMcpAssignments
|
|
||||||
? {
|
|
||||||
...loaded,
|
|
||||||
mcpServers: loaded.mcpServers.map((server) => ({
|
|
||||||
...server,
|
|
||||||
assignments: server.assignments.includes('opencode')
|
|
||||||
? (['model'] as CapabilityAssignments)
|
|
||||||
: server.assignments
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
: loaded
|
|
||||||
this.state = storedCapabilitiesSchema.parse(migrated)
|
|
||||||
await this.validateBrowserProfileReferences(this.state)
|
await this.validateBrowserProfileReferences(this.state)
|
||||||
if (shouldPersist || migrateMcpAssignments) {
|
if (shouldPersist) {
|
||||||
await this.persist(this.state)
|
await this.persist(this.state)
|
||||||
}
|
}
|
||||||
return this.state
|
return this.state
|
||||||
@@ -1609,17 +1595,6 @@ export class CapabilityService {
|
|||||||
): Promise<CapabilitySnapshot> {
|
): Promise<CapabilitySnapshot> {
|
||||||
return this.queue(async () => {
|
return this.queue(async () => {
|
||||||
const value = mcpServerInputSchema.parse(input)
|
const value = mcpServerInputSchema.parse(input)
|
||||||
if (
|
|
||||||
value.assignments.some(
|
|
||||||
(assignment) =>
|
|
||||||
assignment !== 'model' &&
|
|
||||||
assignment !== 'deepseek-harness'
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
'当前版本的 MCP Server 只能分配给直连模型或 DeepSeek Harness'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const state = await this.load()
|
const state = await this.load()
|
||||||
const id = serverId ? mcpServerIdSchema.parse(serverId) : randomUUID()
|
const id = serverId ? mcpServerIdSchema.parse(serverId) : randomUUID()
|
||||||
const existing = state.mcpServers.find((server) => server.id === id)
|
const existing = state.mcpServers.find((server) => server.id === id)
|
||||||
@@ -1818,9 +1793,6 @@ export class CapabilityService {
|
|||||||
async getResolvedMcpServers(
|
async getResolvedMcpServers(
|
||||||
target: RuntimeTarget
|
target: RuntimeTarget
|
||||||
): Promise<ResolvedMcpServer[]> {
|
): Promise<ResolvedMcpServer[]> {
|
||||||
if (target !== 'model' && target !== 'deepseek-harness') {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
const state = await this.load()
|
const state = await this.load()
|
||||||
const assigned = state.mcpServers.filter(
|
const assigned = state.mcpServers.filter(
|
||||||
(server) => server.enabled && server.assignments.includes(target)
|
(server) => server.enabled && server.assignments.includes(target)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
DEEPSEEK_HARNESS_CONTROL_VERSION,
|
DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||||
parseHarnessControlMessage,
|
parseHarnessControlMessage,
|
||||||
type DeepSeekHarnessControlMessage
|
type DeepSeekHarnessControlMessage
|
||||||
} from './agent/deepseek-harness-utility-launcher'
|
} from './agent/deepseek-harness-control-protocol'
|
||||||
import { createDeepSeekHarnessHostTransport } from './agent/deepseek-harness-utility-transport'
|
import { createDeepSeekHarnessHostTransport } from './agent/deepseek-harness-utility-transport'
|
||||||
import {
|
import {
|
||||||
createBoundedNdJsonStream,
|
createBoundedNdJsonStream,
|
||||||
@@ -85,7 +85,8 @@ parentPort.on('message', (event) => {
|
|||||||
post({
|
post({
|
||||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||||
type: 'ready'
|
type: 'ready',
|
||||||
|
failedExtensionIds: host.failedExtensionIds
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
|
|||||||
@@ -147,6 +147,51 @@ describe('controlled DeepSeek Harness host', () => {
|
|||||||
await host.dispose()
|
await host.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reports extension startup failures without failing the Host', async () => {
|
||||||
|
const root = await realpath(
|
||||||
|
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-extensions-'))
|
||||||
|
)
|
||||||
|
const brokenEntrypoint = join(root, 'broken.mjs')
|
||||||
|
await writeFile(
|
||||||
|
brokenEntrypoint,
|
||||||
|
'export const value = 1\n',
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const inbound = new TransformStream<
|
||||||
|
Record<string, unknown>,
|
||||||
|
Record<string, unknown>
|
||||||
|
>()
|
||||||
|
const outbound = new TransformStream<
|
||||||
|
Record<string, unknown>,
|
||||||
|
Record<string, unknown>
|
||||||
|
>()
|
||||||
|
const host = await startControlledDeepSeekHarnessHost({
|
||||||
|
workspace: root,
|
||||||
|
dshHome: root,
|
||||||
|
baseUrl: 'https://api.deepseek.com',
|
||||||
|
api: 'openai-completions',
|
||||||
|
provider: 'goodbuddy',
|
||||||
|
model: 'deepseek-test',
|
||||||
|
harnessVersion: '0.1.0-rc.6',
|
||||||
|
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||||
|
skillPackages: [],
|
||||||
|
extensionPackages: [
|
||||||
|
{
|
||||||
|
id: 'broken',
|
||||||
|
entrypoint: brokenEntrypoint,
|
||||||
|
configuration: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
stream: {
|
||||||
|
readable: inbound.readable,
|
||||||
|
writable: outbound.writable
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(host.failedExtensionIds).toEqual(['broken'])
|
||||||
|
await host.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
it('loads only explicitly supplied Skill packages into a session scope', async () => {
|
it('loads only explicitly supplied Skill packages into a session scope', async () => {
|
||||||
const root = await realpath(
|
const root = await realpath(
|
||||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-skill-'))
|
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-skill-'))
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import {
|
|||||||
createBoundedAcpStream,
|
createBoundedAcpStream,
|
||||||
type GoodBuddyHarnessControlConfig
|
type GoodBuddyHarnessControlConfig
|
||||||
} from './agent/goodbuddy-harness-control-plane'
|
} from './agent/goodbuddy-harness-control-plane'
|
||||||
|
import {
|
||||||
|
loadControlledHarnessExtensions,
|
||||||
|
type ControlledHarnessExtensionPackage
|
||||||
|
} from './agent/deepseek-harness-extension-loader'
|
||||||
import type { Stream } from '@agentclientprotocol/sdk'
|
import type { Stream } from '@agentclientprotocol/sdk'
|
||||||
import { isDeepSeekHarnessCompatibleBaseUrl } from '../shared/deepseek-harness-compatibility'
|
import { isDeepSeekHarnessCompatibleBaseUrl } from '../shared/deepseek-harness-compatibility'
|
||||||
|
|
||||||
@@ -45,11 +49,17 @@ export type ControlledHarnessHostConfig = Omit<
|
|||||||
id: string
|
id: string
|
||||||
directory: string
|
directory: string
|
||||||
}[]
|
}[]
|
||||||
|
extensionPackages?: readonly ControlledHarnessExtensionPackage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ControlledHarnessHost = {
|
export type ControlledHarnessHost = {
|
||||||
readonly context: Context
|
readonly context: Context
|
||||||
readonly controlPlane: GoodBuddyHarnessControlPlane
|
readonly controlPlane: GoodBuddyHarnessControlPlane
|
||||||
|
readonly failedExtensionIds: readonly string[]
|
||||||
|
readonly extensionFailures: readonly {
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
}[]
|
||||||
dispose(): Promise<void>
|
dispose(): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +133,25 @@ async function canonicalizeHostConfig(
|
|||||||
return { ...skill, directory }
|
return { ...skill, directory }
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
return { ...config, workspace, dshHome, skillPackages }
|
const extensionPackages = await Promise.all(
|
||||||
|
(config.extensionPackages ?? []).map(async (extension) => {
|
||||||
|
const entrypoint = await realpath(extension.entrypoint)
|
||||||
|
const metadata = await stat(entrypoint)
|
||||||
|
if (!metadata.isFile()) {
|
||||||
|
throw new Error(
|
||||||
|
'Controlled Harness extension entrypoint must be a file'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return { ...extension, entrypoint }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
workspace,
|
||||||
|
dshHome,
|
||||||
|
skillPackages,
|
||||||
|
extensionPackages
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadControlledSkills(
|
async function loadControlledSkills(
|
||||||
@@ -175,9 +203,10 @@ async function loadControlledSkills(
|
|||||||
/**
|
/**
|
||||||
* Boots a fixed, programmatic Cordis graph. It never imports app-boot, a
|
* Boots a fixed, programmatic Cordis graph. It never imports app-boot, a
|
||||||
* profile loader, settings-file, local credentials, persistence, telemetry,
|
* profile loader, settings-file, local credentials, persistence, telemetry,
|
||||||
* web, HMR, marketplace/plugin discovery, direct MCP clients, jobs,
|
* web, HMR, marketplace discovery, direct MCP clients, jobs, subagents,
|
||||||
* subagents, hooks, or workflow packages. The control plane registers only
|
* hooks, or workflow packages. The control plane registers Main-selected
|
||||||
* Main-selected Skill snapshots and Main-mediated MCP tool proxies.
|
* Skill snapshots, Main-mediated MCP tool proxies, and explicitly enabled
|
||||||
|
* extension entrypoints.
|
||||||
*/
|
*/
|
||||||
export async function startControlledDeepSeekHarnessHost(
|
export async function startControlledDeepSeekHarnessHost(
|
||||||
input: ControlledHarnessHostConfig
|
input: ControlledHarnessHostConfig
|
||||||
@@ -256,6 +285,10 @@ export async function startControlledDeepSeekHarnessHost(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
await Promise.all(fibers)
|
await Promise.all(fibers)
|
||||||
|
const extensions = await loadControlledHarnessExtensions(
|
||||||
|
ctx,
|
||||||
|
config.extensionPackages ?? []
|
||||||
|
)
|
||||||
const credentialProvider = ctx.credentials
|
const credentialProvider = ctx.credentials
|
||||||
if (!(credentialProvider instanceof GoodBuddyCredentialProvider)) {
|
if (!(credentialProvider instanceof GoodBuddyCredentialProvider)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -284,6 +317,8 @@ export async function startControlledDeepSeekHarnessHost(
|
|||||||
return {
|
return {
|
||||||
context: ctx,
|
context: ctx,
|
||||||
controlPlane,
|
controlPlane,
|
||||||
|
failedExtensionIds: extensions.failedIds,
|
||||||
|
extensionFailures: extensions.failures,
|
||||||
async dispose() {
|
async dispose() {
|
||||||
await controlPlane.dispose()
|
await controlPlane.dispose()
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { mkdtemp, realpath } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { startControlledDeepSeekHarnessHost } from './deepseek-harness-host'
|
||||||
|
|
||||||
|
const entrypoint =
|
||||||
|
process.env.GOODBUDDY_DSH_PLUGIN_ENTRYPOINT?.trim()
|
||||||
|
|
||||||
|
describe.skipIf(!entrypoint)(
|
||||||
|
'controlled DeepSeek Harness third-party plugin',
|
||||||
|
() => {
|
||||||
|
it('loads and executes a real marketplace tool with full Execute capability', async () => {
|
||||||
|
const workspace = await realpath(
|
||||||
|
await mkdtemp(
|
||||||
|
join(tmpdir(), 'goodbuddy-harness-marketplace-e2e-')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const inbound = new TransformStream<
|
||||||
|
Record<string, unknown>,
|
||||||
|
Record<string, unknown>
|
||||||
|
>()
|
||||||
|
const outbound = new TransformStream<
|
||||||
|
Record<string, unknown>,
|
||||||
|
Record<string, unknown>
|
||||||
|
>()
|
||||||
|
const host = await startControlledDeepSeekHarnessHost({
|
||||||
|
workspace,
|
||||||
|
dshHome: workspace,
|
||||||
|
baseUrl: 'https://api.deepseek.com',
|
||||||
|
api: 'openai-completions',
|
||||||
|
provider: 'goodbuddy',
|
||||||
|
model: 'deepseek-test',
|
||||||
|
harnessVersion: '0.1.0-rc.6',
|
||||||
|
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||||
|
skillPackages: [],
|
||||||
|
extensionPackages: [
|
||||||
|
{
|
||||||
|
id: 'marketplace-e2e',
|
||||||
|
entrypoint: entrypoint!,
|
||||||
|
configuration: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
stream: {
|
||||||
|
readable: inbound.readable,
|
||||||
|
writable: outbound.writable
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(host.extensionFailures).toEqual([])
|
||||||
|
expect(
|
||||||
|
host.context.tools.schemas().map((tool) => tool.name)
|
||||||
|
).toContain('greet')
|
||||||
|
await expect(
|
||||||
|
host.context.tools.execute({
|
||||||
|
callId: 'marketplace-greet',
|
||||||
|
name: 'greet',
|
||||||
|
arguments: { name: 'Ada' },
|
||||||
|
signal: new AbortController().signal
|
||||||
|
} as never)
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
isError: false,
|
||||||
|
value: 'Hello, Ada!'
|
||||||
|
})
|
||||||
|
await host.dispose()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
+43
-7
@@ -83,6 +83,11 @@ import {
|
|||||||
} from './agent/deepseek-harness-utility-launcher'
|
} from './agent/deepseek-harness-utility-launcher'
|
||||||
import { buildControlledHarnessEnvironment } from './agent/process-environment'
|
import { buildControlledHarnessEnvironment } from './agent/process-environment'
|
||||||
import { runStartupPrerequisites } from './startup-prerequisites'
|
import { runStartupPrerequisites } from './startup-prerequisites'
|
||||||
|
import { RuntimeExtensionStore } from './agent/runtime-extension-store'
|
||||||
|
import {
|
||||||
|
DshNpmExtensionInstaller,
|
||||||
|
DshNpmMarketplaceCatalog
|
||||||
|
} from './agent/dsh-extension-marketplace'
|
||||||
|
|
||||||
const shortcut = 'CommandOrControl+Shift+Space'
|
const shortcut = 'CommandOrControl+Shift+Space'
|
||||||
const mainModuleDirectory = dirname(fileURLToPath(import.meta.url))
|
const mainModuleDirectory = dirname(fileURLToPath(import.meta.url))
|
||||||
@@ -447,6 +452,31 @@ if (hasSingleInstanceLock) {
|
|||||||
app.getPath('userData'),
|
app.getPath('userData'),
|
||||||
'deepseek-harness'
|
'deepseek-harness'
|
||||||
)
|
)
|
||||||
|
const dshExtensionInstaller = new DshNpmExtensionInstaller({
|
||||||
|
dshHome: deepSeekHarnessHome,
|
||||||
|
npmCliPath: app.isPackaged
|
||||||
|
? join(
|
||||||
|
process.resourcesPath,
|
||||||
|
'runtimes',
|
||||||
|
'npm',
|
||||||
|
'bin',
|
||||||
|
'npm-cli.js'
|
||||||
|
)
|
||||||
|
: join(
|
||||||
|
app.getAppPath(),
|
||||||
|
'node_modules',
|
||||||
|
'npm',
|
||||||
|
'bin',
|
||||||
|
'npm-cli.js'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const runtimeExtensionStore = new RuntimeExtensionStore(
|
||||||
|
app.getPath('userData'),
|
||||||
|
{
|
||||||
|
catalog: new DshNpmMarketplaceCatalog(),
|
||||||
|
install: (input) => dshExtensionInstaller.install(input)
|
||||||
|
}
|
||||||
|
)
|
||||||
const launchDeepSeekHarness =
|
const launchDeepSeekHarness =
|
||||||
createDeepSeekHarnessUtilityLauncher({
|
createDeepSeekHarnessUtilityLauncher({
|
||||||
bundledHostPath: bundledRuntimePaths.deepseekHarness,
|
bundledHostPath: bundledRuntimePaths.deepseekHarness,
|
||||||
@@ -455,7 +485,9 @@ if (hasSingleInstanceLock) {
|
|||||||
deepSeekHarnessHome
|
deepSeekHarnessHome
|
||||||
),
|
),
|
||||||
fork: forkDeepSeekHarness,
|
fork: forkDeepSeekHarness,
|
||||||
terminateProcess: terminateHarnessUtilityProcess
|
terminateProcess: terminateHarnessUtilityProcess,
|
||||||
|
onExtensionStartupFailures: (extensionIds) =>
|
||||||
|
runtimeExtensionStore.markStartupFailed(extensionIds)
|
||||||
})
|
})
|
||||||
const startupKnowledgeService = new KnowledgeService({
|
const startupKnowledgeService = new KnowledgeService({
|
||||||
databasePath: join(app.getPath('userData'), 'knowledge.sqlite'),
|
databasePath: join(app.getPath('userData'), 'knowledge.sqlite'),
|
||||||
@@ -488,13 +520,12 @@ if (hasSingleInstanceLock) {
|
|||||||
skillContext,
|
skillContext,
|
||||||
mcpServers,
|
mcpServers,
|
||||||
browserCapability,
|
browserCapability,
|
||||||
webSearchCapability
|
webSearchCapability,
|
||||||
|
deepseekHarnessExtensions
|
||||||
] =
|
] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
capabilityService.getRuntimeSkillContext(target),
|
capabilityService.getRuntimeSkillContext(target),
|
||||||
target === 'model' || target === 'deepseek-harness'
|
capabilityService.getResolvedMcpServers(target),
|
||||||
? capabilityService.getResolvedMcpServers(target)
|
|
||||||
: Promise.resolve([]),
|
|
||||||
target === 'model'
|
target === 'model'
|
||||||
? capabilityService.getComputerCapabilityStatus(
|
? capabilityService.getComputerCapabilityStatus(
|
||||||
'host-browser-control'
|
'host-browser-control'
|
||||||
@@ -502,7 +533,10 @@ if (hasSingleInstanceLock) {
|
|||||||
: Promise.resolve(undefined),
|
: Promise.resolve(undefined),
|
||||||
target === 'model'
|
target === 'model'
|
||||||
? capabilityService.getWebSearchCapabilityStatus()
|
? capabilityService.getWebSearchCapabilityStatus()
|
||||||
: Promise.resolve(undefined)
|
: Promise.resolve(undefined),
|
||||||
|
target === 'deepseek-harness'
|
||||||
|
? runtimeExtensionStore.getEnabledExtensions()
|
||||||
|
: Promise.resolve([])
|
||||||
])
|
])
|
||||||
return createAgentRuntime(defaultWorkspace, settings, {
|
return createAgentRuntime(defaultWorkspace, settings, {
|
||||||
skillInstructions: skillContext.instructions,
|
skillInstructions: skillContext.instructions,
|
||||||
@@ -515,6 +549,7 @@ if (hasSingleInstanceLock) {
|
|||||||
bundledRuntimePaths,
|
bundledRuntimePaths,
|
||||||
continueHostLauncher: launchContinueHost,
|
continueHostLauncher: launchContinueHost,
|
||||||
deepseekHarnessLauncher: launchDeepSeekHarness,
|
deepseekHarnessLauncher: launchDeepSeekHarness,
|
||||||
|
deepseekHarnessExtensions,
|
||||||
browserService:
|
browserService:
|
||||||
browserCapability?.enabled && browserCapability.supported
|
browserCapability?.enabled && browserCapability.supported
|
||||||
? browserService
|
? browserService
|
||||||
@@ -674,7 +709,8 @@ if (hasSingleInstanceLock) {
|
|||||||
documentOcrModelManager,
|
documentOcrModelManager,
|
||||||
documentOcrBroker,
|
documentOcrBroker,
|
||||||
releaseNotesService,
|
releaseNotesService,
|
||||||
goodbuddyConfigService
|
goodbuddyConfigService,
|
||||||
|
runtimeExtensionStore
|
||||||
)
|
)
|
||||||
loadMainWindow(mainWindow)
|
loadMainWindow(mainWindow)
|
||||||
|
|
||||||
|
|||||||
@@ -357,6 +357,149 @@ vi.mock('./channels/channel-env', () => ({
|
|||||||
)
|
)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
describe('registerIpcHandlers DSH runtime extensions', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
electronMocks.handlers.clear()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates extension actions, reloads the Runtime, and trusts only the renderer', async () => {
|
||||||
|
const webContents = {
|
||||||
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||||
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||||
|
isDestroyed: vi.fn(() => false),
|
||||||
|
send: vi.fn()
|
||||||
|
}
|
||||||
|
const window = {
|
||||||
|
webContents,
|
||||||
|
isDestroyed: vi.fn(() => false),
|
||||||
|
isMaximized: vi.fn(() => false),
|
||||||
|
on: vi.fn(),
|
||||||
|
removeListener: vi.fn()
|
||||||
|
}
|
||||||
|
const snapshot = {
|
||||||
|
marketplaceEnabled: true,
|
||||||
|
catalog: [],
|
||||||
|
installed: []
|
||||||
|
}
|
||||||
|
const runtimeExtensionStore = {
|
||||||
|
getSnapshot: vi.fn(async () => snapshot),
|
||||||
|
applyWithResult: vi.fn(async () => ({
|
||||||
|
snapshot,
|
||||||
|
changed: true
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
const onRuntimeSettingsChanged = vi.fn(async () => undefined)
|
||||||
|
const dispose = registerIpcHandlers(
|
||||||
|
window as never,
|
||||||
|
{ capability: 'text' } as never,
|
||||||
|
'CommandOrControl+Shift+Space',
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ clear: vi.fn() } as never,
|
||||||
|
{} as never,
|
||||||
|
{ claimDueSchedules: vi.fn(() => []) } as never,
|
||||||
|
{ clear: vi.fn() } as never,
|
||||||
|
{} as never,
|
||||||
|
onRuntimeSettingsChanged,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
runtimeExtensionStore as never
|
||||||
|
)
|
||||||
|
const event = {
|
||||||
|
sender: webContents,
|
||||||
|
senderFrame: webContents.mainFrame
|
||||||
|
}
|
||||||
|
const action = {
|
||||||
|
type: 'set-enabled',
|
||||||
|
extensionId: 'dsh-plugin-greet',
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
electronMocks.handlers.get(
|
||||||
|
ipcChannels.runtimeExtensionsSnapshot
|
||||||
|
)?.(event)
|
||||||
|
).resolves.toEqual(snapshot)
|
||||||
|
await expect(
|
||||||
|
electronMocks.handlers.get(
|
||||||
|
ipcChannels.runtimeExtensionsApply
|
||||||
|
)?.(event, action)
|
||||||
|
).resolves.toEqual(snapshot)
|
||||||
|
expect(
|
||||||
|
runtimeExtensionStore.applyWithResult
|
||||||
|
).toHaveBeenCalledWith(action)
|
||||||
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
||||||
|
const marketplaceAction = {
|
||||||
|
type: 'set-marketplace-enabled',
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
runtimeExtensionStore.applyWithResult.mockResolvedValueOnce({
|
||||||
|
snapshot: {
|
||||||
|
...snapshot,
|
||||||
|
marketplaceEnabled: false
|
||||||
|
},
|
||||||
|
changed: true
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
electronMocks.handlers.get(
|
||||||
|
ipcChannels.runtimeExtensionsApply
|
||||||
|
)?.(event, marketplaceAction)
|
||||||
|
).resolves.toEqual({
|
||||||
|
...snapshot,
|
||||||
|
marketplaceEnabled: false
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
runtimeExtensionStore.applyWithResult
|
||||||
|
).toHaveBeenLastCalledWith(marketplaceAction)
|
||||||
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
||||||
|
runtimeExtensionStore.applyWithResult.mockResolvedValueOnce({
|
||||||
|
snapshot,
|
||||||
|
changed: false
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
electronMocks.handlers.get(
|
||||||
|
ipcChannels.runtimeExtensionsApply
|
||||||
|
)?.(event, action)
|
||||||
|
).resolves.toEqual(snapshot)
|
||||||
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
electronMocks.handlers.get(
|
||||||
|
ipcChannels.runtimeExtensionsApply
|
||||||
|
)?.(event, {
|
||||||
|
...action,
|
||||||
|
extensionId: 'Invalid Extension'
|
||||||
|
})
|
||||||
|
).rejects.toThrow()
|
||||||
|
expect(() =>
|
||||||
|
electronMocks.handlers.get(
|
||||||
|
ipcChannels.runtimeExtensionsSnapshot
|
||||||
|
)?.({
|
||||||
|
sender: {},
|
||||||
|
senderFrame: webContents.mainFrame
|
||||||
|
})
|
||||||
|
).toThrow('拒绝来自未知窗口的 IPC 请求')
|
||||||
|
|
||||||
|
await dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('registerIpcHandlers lifecycle tracking', () => {
|
describe('registerIpcHandlers lifecycle tracking', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
electronMocks.handlers.clear()
|
electronMocks.handlers.clear()
|
||||||
|
|||||||
+41
-1
@@ -177,6 +177,11 @@ import {
|
|||||||
import type { CapabilityService } from './capabilities/capability-service'
|
import type { CapabilityService } from './capabilities/capability-service'
|
||||||
import { testMcpServer } from './capabilities/mcp-tester'
|
import { testMcpServer } from './capabilities/mcp-tester'
|
||||||
import { testWebSearch } from './capabilities/web-search-tester'
|
import { testWebSearch } from './capabilities/web-search-tester'
|
||||||
|
import {
|
||||||
|
runtimeExtensionActionSchema,
|
||||||
|
type RuntimeExtensionMarketplaceSnapshot
|
||||||
|
} from '../shared/runtime-extension-contracts'
|
||||||
|
import type { RuntimeExtensionStore } from './agent/runtime-extension-store'
|
||||||
import type { ContextManager } from './context-manager'
|
import type { ContextManager } from './context-manager'
|
||||||
import type { KnowledgeService } from './knowledge/knowledge-service'
|
import type { KnowledgeService } from './knowledge/knowledge-service'
|
||||||
import {
|
import {
|
||||||
@@ -831,7 +836,8 @@ export function registerIpcHandlers(
|
|||||||
documentOcrModelManager?: DocumentOcrModelManager,
|
documentOcrModelManager?: DocumentOcrModelManager,
|
||||||
documentOcrBroker?: DocumentOcrBroker,
|
documentOcrBroker?: DocumentOcrBroker,
|
||||||
releaseNotesService?: ReleaseNotesService,
|
releaseNotesService?: ReleaseNotesService,
|
||||||
goodbuddyConfigService?: GoodBuddyConfigService
|
goodbuddyConfigService?: GoodBuddyConfigService,
|
||||||
|
runtimeExtensionStore?: RuntimeExtensionStore
|
||||||
): () => Promise<void> {
|
): () => Promise<void> {
|
||||||
const activeRequests = new Map<string, AbortController>()
|
const activeRequests = new Map<string, AbortController>()
|
||||||
const activeEventBuffers = new Map<string, { flush(): void }>()
|
const activeEventBuffers = new Map<string, { flush(): void }>()
|
||||||
@@ -4030,6 +4036,40 @@ export function registerIpcHandlers(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
registerHandler(
|
||||||
|
ipcChannels.runtimeExtensionsSnapshot,
|
||||||
|
(event): Promise<RuntimeExtensionMarketplaceSnapshot> => {
|
||||||
|
assertTrustedSender(event, window)
|
||||||
|
if (!runtimeExtensionStore) {
|
||||||
|
throw new Error('DSH 插件市场不可用')
|
||||||
|
}
|
||||||
|
return runtimeExtensionStore.getSnapshot()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
registerHandler(
|
||||||
|
ipcChannels.runtimeExtensionsApply,
|
||||||
|
async (
|
||||||
|
event,
|
||||||
|
input: unknown
|
||||||
|
): Promise<RuntimeExtensionMarketplaceSnapshot> => {
|
||||||
|
assertTrustedSender(event, window)
|
||||||
|
if (!runtimeExtensionStore) {
|
||||||
|
throw new Error('DSH 插件市场不可用')
|
||||||
|
}
|
||||||
|
const action = runtimeExtensionActionSchema.parse(input)
|
||||||
|
const result =
|
||||||
|
await runtimeExtensionStore.applyWithResult(action)
|
||||||
|
if (
|
||||||
|
result.changed &&
|
||||||
|
action.type !== 'set-marketplace-enabled'
|
||||||
|
) {
|
||||||
|
await onRuntimeSettingsChanged()
|
||||||
|
}
|
||||||
|
return result.snapshot
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
registerHandler(
|
registerHandler(
|
||||||
ipcChannels.capabilitiesImportSkill,
|
ipcChannels.capabilitiesImportSkill,
|
||||||
async (event, input: unknown): Promise<CapabilitySnapshot> => {
|
async (event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ import type {
|
|||||||
KnowledgeRetrieveInput,
|
KnowledgeRetrieveInput,
|
||||||
KnowledgeSettingsUpdateInput
|
KnowledgeSettingsUpdateInput
|
||||||
} from '../shared/knowledge-contracts'
|
} from '../shared/knowledge-contracts'
|
||||||
|
import type {
|
||||||
|
RuntimeExtensionAction,
|
||||||
|
RuntimeExtensionMarketplaceSnapshot
|
||||||
|
} from '../shared/runtime-extension-contracts'
|
||||||
|
|
||||||
const desktopApi: DesktopApi = {
|
const desktopApi: DesktopApi = {
|
||||||
app: {
|
app: {
|
||||||
@@ -861,6 +865,17 @@ const desktopApi: DesktopApi = {
|
|||||||
profileId
|
profileId
|
||||||
}) as Promise<CapabilitySnapshot>
|
}) as Promise<CapabilitySnapshot>
|
||||||
},
|
},
|
||||||
|
runtimeExtensions: {
|
||||||
|
getSnapshot: () =>
|
||||||
|
ipcRenderer.invoke(
|
||||||
|
ipcChannels.runtimeExtensionsSnapshot
|
||||||
|
) as Promise<RuntimeExtensionMarketplaceSnapshot>,
|
||||||
|
apply: (action: RuntimeExtensionAction) =>
|
||||||
|
ipcRenderer.invoke(
|
||||||
|
ipcChannels.runtimeExtensionsApply,
|
||||||
|
action
|
||||||
|
) as Promise<RuntimeExtensionMarketplaceSnapshot>
|
||||||
|
},
|
||||||
context: {
|
context: {
|
||||||
selectFiles: () =>
|
selectFiles: () =>
|
||||||
ipcRenderer.invoke(
|
ipcRenderer.invoke(
|
||||||
|
|||||||
@@ -482,6 +482,18 @@ const api: DesktopApi = {
|
|||||||
tools: []
|
tools: []
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
runtimeExtensions: {
|
||||||
|
getSnapshot: vi.fn(async () => ({
|
||||||
|
marketplaceEnabled: false,
|
||||||
|
catalog: [],
|
||||||
|
installed: []
|
||||||
|
})),
|
||||||
|
apply: vi.fn(async () => ({
|
||||||
|
marketplaceEnabled: false,
|
||||||
|
catalog: [],
|
||||||
|
installed: []
|
||||||
|
}))
|
||||||
|
},
|
||||||
context: {
|
context: {
|
||||||
selectFiles: vi.fn(async () => []),
|
selectFiles: vi.fn(async () => []),
|
||||||
onFileSelectionProgress: vi.fn((listener) => {
|
onFileSelectionProgress: vi.fn((listener) => {
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor
|
||||||
|
} from '@testing-library/react'
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { DesktopApi } from '../../shared/contracts'
|
||||||
|
import type {
|
||||||
|
RuntimeExtensionCatalogEntry,
|
||||||
|
RuntimeExtensionMarketplaceInstalledState,
|
||||||
|
RuntimeExtensionMarketplaceSnapshot
|
||||||
|
} from '../../shared/runtime-extension-contracts'
|
||||||
|
import { DshMarketplaceSection } from './DshMarketplaceSection'
|
||||||
|
|
||||||
|
const greet: RuntimeExtensionCatalogEntry = {
|
||||||
|
id: 'dsh-plugin-greet',
|
||||||
|
package: {
|
||||||
|
name: 'dsh-plugin-greet',
|
||||||
|
version: '0.1.0'
|
||||||
|
},
|
||||||
|
displayName: 'Greet',
|
||||||
|
description: 'A deterministic greeting tool.',
|
||||||
|
license: 'MIT'
|
||||||
|
}
|
||||||
|
|
||||||
|
const finder: RuntimeExtensionCatalogEntry = {
|
||||||
|
id: 'dsh-find-plugin',
|
||||||
|
package: {
|
||||||
|
name: 'dsh-find-plugin',
|
||||||
|
version: '0.3.6'
|
||||||
|
},
|
||||||
|
displayName: 'Plugin Finder',
|
||||||
|
description: 'Find DSH plugins.',
|
||||||
|
license: 'MIT'
|
||||||
|
}
|
||||||
|
|
||||||
|
const installedGreet: RuntimeExtensionMarketplaceInstalledState = {
|
||||||
|
id: greet.id,
|
||||||
|
package: greet.package,
|
||||||
|
installedAt: '2026-08-16T00:00:00.000Z',
|
||||||
|
enabled: true,
|
||||||
|
configuration: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function marketplaceSnapshot(
|
||||||
|
installed: RuntimeExtensionMarketplaceInstalledState[] = [],
|
||||||
|
marketplaceEnabled = true
|
||||||
|
): RuntimeExtensionMarketplaceSnapshot {
|
||||||
|
return {
|
||||||
|
marketplaceEnabled,
|
||||||
|
catalog: [greet, finder],
|
||||||
|
installed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let getSnapshot: ReturnType<typeof vi.fn>
|
||||||
|
let apply: ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
getSnapshot = vi.fn(async () => marketplaceSnapshot())
|
||||||
|
apply = vi.fn(async () => marketplaceSnapshot([installedGreet]))
|
||||||
|
Object.defineProperty(window, 'goodbuddy', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
runtimeExtensions: {
|
||||||
|
getSnapshot,
|
||||||
|
apply
|
||||||
|
}
|
||||||
|
} as unknown as DesktopApi
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DshMarketplaceSection', () => {
|
||||||
|
it('starts off, loads no catalog UI, and can be enabled explicitly', async () => {
|
||||||
|
const disabledSnapshot = {
|
||||||
|
...marketplaceSnapshot([], false),
|
||||||
|
catalog: []
|
||||||
|
}
|
||||||
|
getSnapshot
|
||||||
|
.mockResolvedValueOnce(disabledSnapshot)
|
||||||
|
.mockResolvedValueOnce(marketplaceSnapshot())
|
||||||
|
apply
|
||||||
|
.mockResolvedValueOnce(marketplaceSnapshot())
|
||||||
|
.mockResolvedValueOnce(disabledSnapshot)
|
||||||
|
|
||||||
|
render(<DshMarketplaceSection onNotify={vi.fn()} />)
|
||||||
|
|
||||||
|
const marketplaceSwitch = await screen.findByRole('switch', {
|
||||||
|
name: '启用 DSH 插件市场'
|
||||||
|
})
|
||||||
|
expect(marketplaceSwitch).not.toBeChecked()
|
||||||
|
expect(
|
||||||
|
screen.getByText(/插件市场默认关闭/)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(marketplaceSwitch)
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(apply).toHaveBeenCalledWith({
|
||||||
|
type: 'set-marketplace-enabled',
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(await screen.findByRole('searchbox')).toBeInTheDocument()
|
||||||
|
expect(getSnapshot).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('switch', {
|
||||||
|
name: '启用 DSH 插件市场'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(apply).toHaveBeenLastCalledWith({
|
||||||
|
type: 'set-marketplace-enabled',
|
||||||
|
enabled: false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(/不会停用或卸载已有插件/)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads the catalog, filters locally, and shows startup failures', async () => {
|
||||||
|
getSnapshot.mockResolvedValueOnce(
|
||||||
|
marketplaceSnapshot([
|
||||||
|
{
|
||||||
|
...installedGreet,
|
||||||
|
enabled: false,
|
||||||
|
lastError: 'Extension failed to start.'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
render(<DshMarketplaceSection onNotify={vi.fn()} />)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('heading', {
|
||||||
|
name: 'DSH 插件市场'
|
||||||
|
})
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(/第三方插件的安装脚本、初始化代码及工具均以当前用户权限运行/)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Greet')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Plugin Finder')).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(/插件上次启动失败,已自动停用/)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole('searchbox'), {
|
||||||
|
target: { value: 'finder' }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.queryByText('Greet')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Plugin Finder')).toBeInTheDocument()
|
||||||
|
expect(getSnapshot).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires explicit current-user permission confirmation before install', async () => {
|
||||||
|
const onNotify = vi.fn()
|
||||||
|
render(<DshMarketplaceSection onNotify={onNotify} />)
|
||||||
|
await screen.findByText('Greet')
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getAllByRole('button', {
|
||||||
|
name: '安装并启用'
|
||||||
|
})[0]!
|
||||||
|
)
|
||||||
|
|
||||||
|
const confirm = screen.getByRole('button', {
|
||||||
|
name: '确认安装'
|
||||||
|
})
|
||||||
|
expect(confirm).toBeDisabled()
|
||||||
|
expect(
|
||||||
|
screen.getByText(/npm 会运行该包及其依赖声明的安装脚本/)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByLabelText(
|
||||||
|
'我信任 dsh-plugin-greet@0.1.0,并了解其代码将以当前用户权限运行。'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: '刷新 DSH 插件市场'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
await waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(2))
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', {
|
||||||
|
name: '确认安装'
|
||||||
|
})
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getAllByRole('button', {
|
||||||
|
name: '安装并启用'
|
||||||
|
})[0]!
|
||||||
|
)
|
||||||
|
const refreshedConfirm = screen.getByRole('button', {
|
||||||
|
name: '确认安装'
|
||||||
|
})
|
||||||
|
expect(refreshedConfirm).toBeDisabled()
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByLabelText(
|
||||||
|
'我信任 dsh-plugin-greet@0.1.0,并了解其代码将以当前用户权限运行。'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fireEvent.click(refreshedConfirm)
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(apply).toHaveBeenCalledWith({
|
||||||
|
type: 'install',
|
||||||
|
extensionId: greet.id,
|
||||||
|
package: greet.package
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(onNotify).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
tone: 'success',
|
||||||
|
message: '已安装并启用 Greet'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles, configures, and removes installed plugins', async () => {
|
||||||
|
getSnapshot.mockResolvedValueOnce(
|
||||||
|
marketplaceSnapshot([installedGreet])
|
||||||
|
)
|
||||||
|
apply.mockImplementation(async (action) => {
|
||||||
|
if (action.type === 'remove') {
|
||||||
|
return marketplaceSnapshot()
|
||||||
|
}
|
||||||
|
return marketplaceSnapshot([
|
||||||
|
{
|
||||||
|
...installedGreet,
|
||||||
|
enabled:
|
||||||
|
action.type === 'set-enabled'
|
||||||
|
? action.enabled
|
||||||
|
: installedGreet.enabled,
|
||||||
|
configuration:
|
||||||
|
action.type === 'configure'
|
||||||
|
? action.configuration
|
||||||
|
: installedGreet.configuration
|
||||||
|
}
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DshMarketplaceSection onNotify={vi.fn()} />)
|
||||||
|
const toggle = await screen.findByRole('switch', {
|
||||||
|
name: '启用 Greet'
|
||||||
|
})
|
||||||
|
fireEvent.click(toggle)
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(apply).toHaveBeenCalledWith({
|
||||||
|
type: 'set-enabled',
|
||||||
|
extensionId: greet.id,
|
||||||
|
enabled: false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '配置' }))
|
||||||
|
const editor = screen.getByRole('textbox', {
|
||||||
|
name: 'Greet 配置 JSON'
|
||||||
|
})
|
||||||
|
fireEvent.change(editor, { target: { value: '[]' } })
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', { name: '保存配置' })
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
screen.getByText('配置必须是有效的 JSON 对象。')
|
||||||
|
).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.change(editor, {
|
||||||
|
target: { value: '{"salutation":"你好"}' }
|
||||||
|
})
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', { name: '保存配置' })
|
||||||
|
)
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(apply).toHaveBeenCalledWith({
|
||||||
|
type: 'configure',
|
||||||
|
extensionId: greet.id,
|
||||||
|
configuration: { salutation: '你好' }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', { name: '移除 Greet' })
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
screen.getByRole('alertdialog', { name: '移除 Greet' })
|
||||||
|
).toHaveAccessibleDescription(
|
||||||
|
'移除 Greet 及其由 GoodBuddy 托管的文件?'
|
||||||
|
)
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', { name: '移除 Greet' })
|
||||||
|
)
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(apply).toHaveBeenCalledWith({
|
||||||
|
type: 'remove',
|
||||||
|
extensionId: greet.id
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps actions responsive through the Strict Mode effect cycle', async () => {
|
||||||
|
const onNotify = vi.fn()
|
||||||
|
getSnapshot.mockResolvedValue(
|
||||||
|
marketplaceSnapshot([installedGreet])
|
||||||
|
)
|
||||||
|
apply.mockResolvedValue(
|
||||||
|
marketplaceSnapshot([
|
||||||
|
{ ...installedGreet, enabled: false }
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
render(
|
||||||
|
<StrictMode>
|
||||||
|
<DshMarketplaceSection onNotify={onNotify} />
|
||||||
|
</StrictMode>
|
||||||
|
)
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole('switch', {
|
||||||
|
name: '启用 Greet'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onNotify).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
tone: 'success'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
screen.getByRole('switch', { name: '启用 Greet' })
|
||||||
|
).not.toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a failed catalog load recoverable', async () => {
|
||||||
|
getSnapshot
|
||||||
|
.mockRejectedValueOnce(new Error('npm registry unavailable'))
|
||||||
|
.mockResolvedValueOnce(marketplaceSnapshot())
|
||||||
|
|
||||||
|
render(<DshMarketplaceSection onNotify={vi.fn()} />)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('alert')
|
||||||
|
).toHaveTextContent('npm registry unavailable')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||||
|
expect(await screen.findByText('Greet')).toBeInTheDocument()
|
||||||
|
expect(getSnapshot).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps installed plugins manageable when npm catalog refresh fails', async () => {
|
||||||
|
getSnapshot.mockResolvedValueOnce({
|
||||||
|
marketplaceEnabled: true,
|
||||||
|
catalog: [],
|
||||||
|
installed: [installedGreet],
|
||||||
|
catalogError: 'npm registry unavailable'
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DshMarketplaceSection onNotify={vi.fn()} />)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('alert')
|
||||||
|
).toHaveTextContent(
|
||||||
|
'无法刷新 npm 插件目录:npm registry unavailable。已安装插件仍可管理。'
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
screen.getByRole('switch', { name: '启用 dsh-plugin-greet' })
|
||||||
|
).toBeChecked()
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: '移除 dsh-plugin-greet'
|
||||||
|
})
|
||||||
|
).toBeEnabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,824 @@
|
|||||||
|
import {
|
||||||
|
Package,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Settings2,
|
||||||
|
Trash2
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
legacyRuntimeExtensionStartupFailure,
|
||||||
|
runtimeExtensionConfigurationSchema,
|
||||||
|
runtimeExtensionStartupFailureCode,
|
||||||
|
type RuntimeExtensionAction,
|
||||||
|
type RuntimeExtensionCatalogEntry,
|
||||||
|
type RuntimeExtensionConfiguration,
|
||||||
|
type RuntimeExtensionMarketplaceInstalledState,
|
||||||
|
type RuntimeExtensionMarketplaceSnapshot
|
||||||
|
} from '../../shared/runtime-extension-contracts'
|
||||||
|
import type { AppNotificationInput } from './notifications'
|
||||||
|
import { DestructiveConfirmActions } from './WorkspacePrimitives'
|
||||||
|
|
||||||
|
const maximumVisibleEntries = 40
|
||||||
|
|
||||||
|
type DshMarketplaceSectionProps = {
|
||||||
|
onNotify: (notification: AppNotificationInput) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function packagesMatch(
|
||||||
|
catalog: RuntimeExtensionCatalogEntry,
|
||||||
|
installed: RuntimeExtensionMarketplaceInstalledState
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
catalog.package.name === installed.package.name &&
|
||||||
|
catalog.package.version === installed.package.version
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageLabel(entry: RuntimeExtensionCatalogEntry): string {
|
||||||
|
return `${entry.package.name}@${entry.package.version}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function installConfirmationIdentity(
|
||||||
|
entry: RuntimeExtensionCatalogEntry
|
||||||
|
): string {
|
||||||
|
return `${entry.id}:${packageLabel(entry)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionIdentity(action: RuntimeExtensionAction): string {
|
||||||
|
return action.type === 'set-marketplace-enabled'
|
||||||
|
? 'marketplace'
|
||||||
|
: action.extensionId
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DshMarketplaceSection({
|
||||||
|
onNotify
|
||||||
|
}: DshMarketplaceSectionProps): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('settings')
|
||||||
|
const [snapshot, setSnapshot] =
|
||||||
|
useState<RuntimeExtensionMarketplaceSnapshot>()
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadError, setLoadError] = useState<string>()
|
||||||
|
const [busy, setBusy] = useState<string>()
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [confirmingInstall, setConfirmingInstall] = useState<string>()
|
||||||
|
const [installConfirmed, setInstallConfirmed] = useState(false)
|
||||||
|
const [confirmingRemove, setConfirmingRemove] = useState<string>()
|
||||||
|
const [configuring, setConfiguring] = useState<string>()
|
||||||
|
const [configurationDraft, setConfigurationDraft] = useState('')
|
||||||
|
const [configurationError, setConfigurationError] = useState<string>()
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
const installConfirmationRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const readSnapshot = useCallback((): Promise<RuntimeExtensionMarketplaceSnapshot> => {
|
||||||
|
const api = window.goodbuddy.runtimeExtensions
|
||||||
|
return api
|
||||||
|
? api.getSnapshot()
|
||||||
|
: Promise.reject(
|
||||||
|
new Error(
|
||||||
|
t(
|
||||||
|
'runtime.deepseekHarness.marketplace.errors.unavailable'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
const load = useCallback(async (): Promise<void> => {
|
||||||
|
setConfirmingInstall(undefined)
|
||||||
|
setInstallConfirmed(false)
|
||||||
|
setLoading(true)
|
||||||
|
setLoadError(undefined)
|
||||||
|
try {
|
||||||
|
const next = await readSnapshot()
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setSnapshot(next)
|
||||||
|
}
|
||||||
|
} catch (reason) {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setLoadError(
|
||||||
|
reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: t('runtime.deepseekHarness.marketplace.errors.readFailed')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [readSnapshot, t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void readSnapshot()
|
||||||
|
.then((next) => {
|
||||||
|
if (active) {
|
||||||
|
setSnapshot(next)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
if (active) {
|
||||||
|
setLoadError(
|
||||||
|
reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: t(
|
||||||
|
'runtime.deepseekHarness.marketplace.errors.readFailed'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [readSnapshot, t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (confirmingInstall) {
|
||||||
|
installConfirmationRef.current?.focus()
|
||||||
|
}
|
||||||
|
}, [confirmingInstall])
|
||||||
|
|
||||||
|
const apply = async (
|
||||||
|
key: string,
|
||||||
|
action: RuntimeExtensionAction,
|
||||||
|
successMessage: string
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const api = window.goodbuddy.runtimeExtensions
|
||||||
|
if (!api) {
|
||||||
|
onNotify({
|
||||||
|
tone: 'error',
|
||||||
|
message: t(
|
||||||
|
'runtime.deepseekHarness.marketplace.errors.unavailable'
|
||||||
|
),
|
||||||
|
dedupeKey: 'dsh-marketplace-unavailable'
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(key)
|
||||||
|
try {
|
||||||
|
const next = await api.apply(action)
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setSnapshot(next)
|
||||||
|
onNotify({
|
||||||
|
tone: 'success',
|
||||||
|
message: successMessage,
|
||||||
|
dedupeKey: `dsh-marketplace-${action.type}-${actionIdentity(action)}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (reason) {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
onNotify({
|
||||||
|
tone: 'error',
|
||||||
|
message:
|
||||||
|
reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: t(
|
||||||
|
'runtime.deepseekHarness.marketplace.errors.operationFailed'
|
||||||
|
),
|
||||||
|
dedupeKey: `dsh-marketplace-error-${action.type}-${actionIdentity(action)}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setBusy(undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setMarketplaceEnabled = async (
|
||||||
|
enabled: boolean
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!enabled) {
|
||||||
|
setConfirmingInstall(undefined)
|
||||||
|
setInstallConfirmed(false)
|
||||||
|
}
|
||||||
|
const changed = await apply(
|
||||||
|
'marketplace',
|
||||||
|
{
|
||||||
|
type: 'set-marketplace-enabled',
|
||||||
|
enabled
|
||||||
|
},
|
||||||
|
t(
|
||||||
|
enabled
|
||||||
|
? 'runtime.deepseekHarness.marketplace.notifications.marketplaceEnabled'
|
||||||
|
: 'runtime.deepseekHarness.marketplace.notifications.marketplaceDisabled'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (changed && enabled && mountedRef.current) {
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const installedById = useMemo(
|
||||||
|
() =>
|
||||||
|
new Map(
|
||||||
|
(snapshot?.installed ?? []).map((extension) => [
|
||||||
|
extension.id,
|
||||||
|
extension
|
||||||
|
])
|
||||||
|
),
|
||||||
|
[snapshot]
|
||||||
|
)
|
||||||
|
|
||||||
|
const entries = useMemo(() => {
|
||||||
|
if (!snapshot) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const knownIds = new Set(snapshot.catalog.map((entry) => entry.id))
|
||||||
|
const installedWithoutCatalog = snapshot.installed
|
||||||
|
.filter((extension) => !knownIds.has(extension.id))
|
||||||
|
.map<RuntimeExtensionCatalogEntry>((extension) => ({
|
||||||
|
id: extension.id,
|
||||||
|
package: extension.package,
|
||||||
|
displayName: extension.package.name,
|
||||||
|
description: t(
|
||||||
|
'runtime.deepseekHarness.marketplace.notInCatalog'
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
const normalizedQuery = query.trim().toLocaleLowerCase()
|
||||||
|
return [...snapshot.catalog, ...installedWithoutCatalog]
|
||||||
|
.filter((entry) => {
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
entry.displayName,
|
||||||
|
entry.description,
|
||||||
|
entry.package.name,
|
||||||
|
entry.license ?? ''
|
||||||
|
].some((value) =>
|
||||||
|
value.toLocaleLowerCase().includes(normalizedQuery)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftInstalled = installedById.has(left.id) ? 0 : 1
|
||||||
|
const rightInstalled = installedById.has(right.id) ? 0 : 1
|
||||||
|
return (
|
||||||
|
leftInstalled - rightInstalled ||
|
||||||
|
left.displayName.localeCompare(right.displayName)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [installedById, query, snapshot, t])
|
||||||
|
|
||||||
|
const visibleEntries = entries.slice(0, maximumVisibleEntries)
|
||||||
|
|
||||||
|
const beginConfiguration = (
|
||||||
|
extension: RuntimeExtensionMarketplaceInstalledState
|
||||||
|
): void => {
|
||||||
|
setConfiguring(extension.id)
|
||||||
|
setConfigurationDraft(
|
||||||
|
JSON.stringify(extension.configuration, null, 2)
|
||||||
|
)
|
||||||
|
setConfigurationError(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveConfiguration = async (
|
||||||
|
extension: RuntimeExtensionMarketplaceInstalledState
|
||||||
|
): Promise<void> => {
|
||||||
|
let configuration: RuntimeExtensionConfiguration
|
||||||
|
try {
|
||||||
|
const parsed = runtimeExtensionConfigurationSchema.safeParse(
|
||||||
|
JSON.parse(configurationDraft) as unknown
|
||||||
|
)
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error('not-an-object')
|
||||||
|
}
|
||||||
|
configuration = parsed.data
|
||||||
|
} catch {
|
||||||
|
setConfigurationError(
|
||||||
|
t(
|
||||||
|
'runtime.deepseekHarness.marketplace.configuration.invalid'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfigurationError(undefined)
|
||||||
|
const saved = await apply(
|
||||||
|
`configure:${extension.id}`,
|
||||||
|
{
|
||||||
|
type: 'configure',
|
||||||
|
extensionId: extension.id,
|
||||||
|
configuration
|
||||||
|
},
|
||||||
|
t('runtime.deepseekHarness.marketplace.notifications.configured', {
|
||||||
|
name: extension.package.name
|
||||||
|
})
|
||||||
|
)
|
||||||
|
if (saved && mountedRef.current) {
|
||||||
|
setConfiguring(undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby="dsh-marketplace-heading"
|
||||||
|
className="settings-section runtime-extension-marketplace"
|
||||||
|
>
|
||||||
|
<div className="settings-section__title settings-section__title--actions">
|
||||||
|
<Package aria-hidden="true" size={17} />
|
||||||
|
<div>
|
||||||
|
<strong
|
||||||
|
aria-level={3}
|
||||||
|
id="dsh-marketplace-heading"
|
||||||
|
role="heading"
|
||||||
|
>
|
||||||
|
{t('runtime.deepseekHarness.marketplace.title')}
|
||||||
|
</strong>
|
||||||
|
<small>
|
||||||
|
{t('runtime.deepseekHarness.marketplace.previewDescription')}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<span className="runtime-extension-marketplace__header-actions">
|
||||||
|
<label className="toggle-row runtime-extension-marketplace__master-toggle">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
snapshot?.marketplaceEnabled
|
||||||
|
? 'runtime.deepseekHarness.marketplace.switch.enabled'
|
||||||
|
: 'runtime.deepseekHarness.marketplace.switch.disabled'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
aria-label={t(
|
||||||
|
'runtime.deepseekHarness.marketplace.switch.aria'
|
||||||
|
)}
|
||||||
|
checked={snapshot?.marketplaceEnabled ?? false}
|
||||||
|
disabled={!snapshot || loading || Boolean(busy)}
|
||||||
|
onChange={(event) =>
|
||||||
|
void setMarketplaceEnabled(event.target.checked)
|
||||||
|
}
|
||||||
|
role="switch"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{snapshot?.marketplaceEnabled && (
|
||||||
|
<button
|
||||||
|
aria-label={t(
|
||||||
|
'runtime.deepseekHarness.marketplace.refreshAria'
|
||||||
|
)}
|
||||||
|
className="secondary-button"
|
||||||
|
disabled={loading || Boolean(busy)}
|
||||||
|
onClick={() => void load()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RefreshCw aria-hidden="true" size={13} />
|
||||||
|
{t('runtime.deepseekHarness.marketplace.refresh')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadError && (
|
||||||
|
<div className="runtime-extension-marketplace__load-error">
|
||||||
|
<p className="settings-warning" role="alert">
|
||||||
|
{loadError}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => void load()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('runtime.deepseekHarness.marketplace.retry')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{snapshot && !snapshot.marketplaceEnabled && (
|
||||||
|
<p className="settings-notice">
|
||||||
|
{t('runtime.deepseekHarness.marketplace.disabledDescription')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{snapshot?.marketplaceEnabled && (
|
||||||
|
<>
|
||||||
|
<p className="settings-warning">
|
||||||
|
{t(
|
||||||
|
'runtime.deepseekHarness.marketplace.permissionNotice'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="field runtime-extension-marketplace__search">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
'runtime.deepseekHarness.marketplace.searchLabel'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="runtime-extension-marketplace__search-input">
|
||||||
|
<Search aria-hidden="true" size={14} />
|
||||||
|
<input
|
||||||
|
onChange={(event) =>
|
||||||
|
setQuery(event.currentTarget.value)
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
'runtime.deepseekHarness.marketplace.searchPlaceholder'
|
||||||
|
)}
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{snapshot.catalogError && (
|
||||||
|
<div className="runtime-extension-marketplace__load-error">
|
||||||
|
<p className="settings-warning" role="alert">
|
||||||
|
{t(
|
||||||
|
'runtime.deepseekHarness.marketplace.catalogUnavailable',
|
||||||
|
{ detail: snapshot.catalogError }
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
disabled={loading || Boolean(busy)}
|
||||||
|
onClick={() => void load()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('runtime.deepseekHarness.marketplace.retry')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="settings-notice" role="status">
|
||||||
|
{t('runtime.deepseekHarness.marketplace.results', {
|
||||||
|
shown: visibleEntries.length,
|
||||||
|
total: entries.length
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="settings-empty">
|
||||||
|
{query.trim()
|
||||||
|
? t('runtime.deepseekHarness.marketplace.noResults')
|
||||||
|
: t('runtime.deepseekHarness.marketplace.empty')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="runtime-extension-marketplace__list">
|
||||||
|
{visibleEntries.map((entry) => {
|
||||||
|
const installed = installedById.get(entry.id)
|
||||||
|
const updateAvailable =
|
||||||
|
Boolean(installed) &&
|
||||||
|
!packagesMatch(entry, installed!)
|
||||||
|
const installPanelOpen =
|
||||||
|
confirmingInstall ===
|
||||||
|
installConfirmationIdentity(entry)
|
||||||
|
const configurationOpen = configuring === entry.id
|
||||||
|
const installBusy = busy === `install:${entry.id}`
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="runtime-extension-card"
|
||||||
|
key={entry.id}
|
||||||
|
>
|
||||||
|
<header className="runtime-extension-card__header">
|
||||||
|
<div>
|
||||||
|
<strong>{entry.displayName}</strong>
|
||||||
|
<code>{packageLabel(entry)}</code>
|
||||||
|
</div>
|
||||||
|
<div className="runtime-extension-card__tags">
|
||||||
|
{installed && (
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
'runtime.deepseekHarness.marketplace.installed'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entry.license && <span>{entry.license}</span>}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p>{entry.description}</p>
|
||||||
|
|
||||||
|
{installed?.lastError && (
|
||||||
|
<p className="settings-warning" role="alert">
|
||||||
|
{installed.lastError ===
|
||||||
|
runtimeExtensionStartupFailureCode ||
|
||||||
|
installed.lastError ===
|
||||||
|
legacyRuntimeExtensionStartupFailure
|
||||||
|
? t(
|
||||||
|
'runtime.deepseekHarness.marketplace.startupFailure'
|
||||||
|
)
|
||||||
|
: installed.lastError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="runtime-extension-card__actions">
|
||||||
|
{installed && (
|
||||||
|
<>
|
||||||
|
<label className="toggle-row runtime-extension-card__toggle">
|
||||||
|
<span>
|
||||||
|
{installed.enabled
|
||||||
|
? t(
|
||||||
|
'runtime.deepseekHarness.marketplace.enabled'
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
'runtime.deepseekHarness.marketplace.disabled'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
aria-label={t(
|
||||||
|
'runtime.deepseekHarness.marketplace.enableAria',
|
||||||
|
{ name: entry.displayName }
|
||||||
|
)}
|
||||||
|
checked={installed.enabled}
|
||||||
|
disabled={Boolean(busy)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const enabled = event.target.checked
|
||||||
|
void apply(
|
||||||
|
`toggle:${entry.id}`,
|
||||||
|
{
|
||||||
|
type: 'set-enabled',
|
||||||
|
extensionId: entry.id,
|
||||||
|
enabled
|
||||||
|
},
|
||||||
|
t(
|
||||||
|
enabled
|
||||||
|
? 'runtime.deepseekHarness.marketplace.notifications.enabled'
|
||||||
|
: 'runtime.deepseekHarness.marketplace.notifications.disabled',
|
||||||
|
{ name: entry.displayName }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
role="switch"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
disabled={Boolean(busy)}
|
||||||
|
onClick={() =>
|
||||||
|
configurationOpen
|
||||||
|
? setConfiguring(undefined)
|
||||||
|
: beginConfiguration(installed)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Settings2 aria-hidden="true" size={13} />
|
||||||
|
{configurationOpen
|
||||||
|
? t(
|
||||||
|
'runtime.deepseekHarness.marketplace.configuration.close'
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
'runtime.deepseekHarness.marketplace.configuration.open'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<DestructiveConfirmActions
|
||||||
|
confirmAriaLabel={t(
|
||||||
|
'runtime.deepseekHarness.marketplace.removeAria',
|
||||||
|
{ name: entry.displayName }
|
||||||
|
)}
|
||||||
|
confirmLabel={t(
|
||||||
|
'runtime.deepseekHarness.marketplace.confirmRemove'
|
||||||
|
)}
|
||||||
|
confirming={
|
||||||
|
confirmingRemove === entry.id
|
||||||
|
}
|
||||||
|
disabled={Boolean(busy)}
|
||||||
|
icon={<Trash2 size={13} />}
|
||||||
|
message={t(
|
||||||
|
'runtime.deepseekHarness.marketplace.removeMessage',
|
||||||
|
{ name: entry.displayName }
|
||||||
|
)}
|
||||||
|
onCancel={() =>
|
||||||
|
setConfirmingRemove(undefined)
|
||||||
|
}
|
||||||
|
onConfirm={() => {
|
||||||
|
void apply(
|
||||||
|
`remove:${entry.id}`,
|
||||||
|
{
|
||||||
|
type: 'remove',
|
||||||
|
extensionId: entry.id
|
||||||
|
},
|
||||||
|
t(
|
||||||
|
'runtime.deepseekHarness.marketplace.notifications.removed',
|
||||||
|
{ name: entry.displayName }
|
||||||
|
)
|
||||||
|
).then((removed) => {
|
||||||
|
if (removed && mountedRef.current) {
|
||||||
|
setConfirmingRemove(undefined)
|
||||||
|
setConfiguring((current) =>
|
||||||
|
current === entry.id
|
||||||
|
? undefined
|
||||||
|
: current
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onRequestConfirm={() =>
|
||||||
|
setConfirmingRemove(entry.id)
|
||||||
|
}
|
||||||
|
triggerAriaLabel={t(
|
||||||
|
'runtime.deepseekHarness.marketplace.removeAria',
|
||||||
|
{ name: entry.displayName }
|
||||||
|
)}
|
||||||
|
triggerLabel={t(
|
||||||
|
'runtime.deepseekHarness.marketplace.remove'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(!installed || updateAvailable) && (
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
disabled={Boolean(busy)}
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmingInstall(
|
||||||
|
installConfirmationIdentity(entry)
|
||||||
|
)
|
||||||
|
setInstallConfirmed(false)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{updateAvailable
|
||||||
|
? t(
|
||||||
|
'runtime.deepseekHarness.marketplace.update',
|
||||||
|
{ version: entry.package.version }
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
'runtime.deepseekHarness.marketplace.install'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{installPanelOpen && (
|
||||||
|
<fieldset className="runtime-extension-install-confirmation">
|
||||||
|
<legend>
|
||||||
|
{t(
|
||||||
|
'runtime.deepseekHarness.marketplace.installConfirmationTitle',
|
||||||
|
{ name: entry.displayName }
|
||||||
|
)}
|
||||||
|
</legend>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
'runtime.deepseekHarness.marketplace.installConfirmation'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
checked={installConfirmed}
|
||||||
|
disabled={installBusy}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInstallConfirmed(event.target.checked)
|
||||||
|
}
|
||||||
|
ref={installConfirmationRef}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
'runtime.deepseekHarness.marketplace.trustConfirmation',
|
||||||
|
{ package: packageLabel(entry) }
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
disabled={installBusy}
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmingInstall(undefined)
|
||||||
|
setInstallConfirmed(false)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
'runtime.deepseekHarness.marketplace.cancel'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
disabled={!installConfirmed || installBusy}
|
||||||
|
onClick={() => {
|
||||||
|
void apply(
|
||||||
|
`install:${entry.id}`,
|
||||||
|
{
|
||||||
|
type: 'install',
|
||||||
|
extensionId: entry.id,
|
||||||
|
package: entry.package
|
||||||
|
},
|
||||||
|
t(
|
||||||
|
updateAvailable
|
||||||
|
? 'runtime.deepseekHarness.marketplace.notifications.updated'
|
||||||
|
: 'runtime.deepseekHarness.marketplace.notifications.installed',
|
||||||
|
{ name: entry.displayName }
|
||||||
|
)
|
||||||
|
).then((installedSuccessfully) => {
|
||||||
|
if (
|
||||||
|
installedSuccessfully &&
|
||||||
|
mountedRef.current
|
||||||
|
) {
|
||||||
|
setConfirmingInstall(undefined)
|
||||||
|
setInstallConfirmed(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{installBusy
|
||||||
|
? t(
|
||||||
|
'runtime.deepseekHarness.marketplace.installing'
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
'runtime.deepseekHarness.marketplace.confirmInstall'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{configurationOpen && installed && (
|
||||||
|
<div className="runtime-extension-configuration">
|
||||||
|
<label className="field">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
'runtime.deepseekHarness.marketplace.configuration.label',
|
||||||
|
{ name: entry.displayName }
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
aria-label={t(
|
||||||
|
'runtime.deepseekHarness.marketplace.configuration.label',
|
||||||
|
{ name: entry.displayName }
|
||||||
|
)}
|
||||||
|
aria-invalid={Boolean(configurationError)}
|
||||||
|
disabled={Boolean(busy)}
|
||||||
|
onChange={(event) => {
|
||||||
|
setConfigurationDraft(
|
||||||
|
event.currentTarget.value
|
||||||
|
)
|
||||||
|
setConfigurationError(undefined)
|
||||||
|
}}
|
||||||
|
spellCheck={false}
|
||||||
|
value={configurationDraft}
|
||||||
|
/>
|
||||||
|
<small>
|
||||||
|
{t(
|
||||||
|
'runtime.deepseekHarness.marketplace.configuration.help'
|
||||||
|
)}
|
||||||
|
</small>
|
||||||
|
{configurationError && (
|
||||||
|
<small className="field-error" role="alert">
|
||||||
|
{configurationError}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
disabled={Boolean(busy)}
|
||||||
|
onClick={() =>
|
||||||
|
void saveConfiguration(installed)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
'runtime.deepseekHarness.marketplace.configuration.save'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entries.length > maximumVisibleEntries && (
|
||||||
|
<p className="settings-notice">
|
||||||
|
{t(
|
||||||
|
'runtime.deepseekHarness.marketplace.refineSearch',
|
||||||
|
{ count: maximumVisibleEntries }
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && !snapshot && (
|
||||||
|
<p className="settings-empty" role="status">
|
||||||
|
{t('runtime.deepseekHarness.marketplace.loading')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -39,6 +39,8 @@ import { PageTabs } from './WorkspacePrimitives'
|
|||||||
|
|
||||||
const configurableMcpTargets: RuntimeTarget[] = [
|
const configurableMcpTargets: RuntimeTarget[] = [
|
||||||
'model',
|
'model',
|
||||||
|
'opencode',
|
||||||
|
'continue',
|
||||||
'deepseek-harness'
|
'deepseek-harness'
|
||||||
]
|
]
|
||||||
type McpSettingsTab = 'builtin' | 'computer' | 'custom'
|
type McpSettingsTab = 'builtin' | 'computer' | 'custom'
|
||||||
@@ -79,9 +81,7 @@ function editorFromServer(server: McpServerSummary): McpEditor {
|
|||||||
description: server.description,
|
description: server.description,
|
||||||
enabled: server.enabled,
|
enabled: server.enabled,
|
||||||
allowDynamicTools: server.allowDynamicTools,
|
allowDynamicTools: server.allowDynamicTools,
|
||||||
assignments: server.assignments.filter((target) =>
|
assignments: server.assignments,
|
||||||
configurableMcpTargets.includes(target)
|
|
||||||
),
|
|
||||||
transport: server.transport,
|
transport: server.transport,
|
||||||
command: server.transport === 'stdio' ? server.command : '',
|
command: server.transport === 'stdio' ? server.command : '',
|
||||||
args: server.transport === 'stdio' ? server.args.join('\n') : '',
|
args: server.transport === 'stdio' ? server.args.join('\n') : '',
|
||||||
|
|||||||
@@ -424,6 +424,17 @@ const selectSpeechModel = vi.fn<
|
|||||||
speechModelSnapshot = createSpeechModelSnapshot(modelId)
|
speechModelSnapshot = createSpeechModelSnapshot(modelId)
|
||||||
return speechModelSnapshot
|
return speechModelSnapshot
|
||||||
})
|
})
|
||||||
|
const runtimeExtensionSnapshot = {
|
||||||
|
marketplaceEnabled: true,
|
||||||
|
catalog: [],
|
||||||
|
installed: []
|
||||||
|
}
|
||||||
|
const getRuntimeExtensionSnapshot = vi.fn(
|
||||||
|
async () => runtimeExtensionSnapshot
|
||||||
|
)
|
||||||
|
const applyRuntimeExtension = vi.fn(
|
||||||
|
async () => runtimeExtensionSnapshot
|
||||||
|
)
|
||||||
|
|
||||||
describe('SettingsPanel runtime files', () => {
|
describe('SettingsPanel runtime files', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -496,6 +507,10 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
openRepository: vi.fn(),
|
openRepository: vi.fn(),
|
||||||
openModelsDirectory: vi.fn()
|
openModelsDirectory: vi.fn()
|
||||||
},
|
},
|
||||||
|
runtimeExtensions: {
|
||||||
|
getSnapshot: getRuntimeExtensionSnapshot,
|
||||||
|
apply: applyRuntimeExtension
|
||||||
|
},
|
||||||
updates: {
|
updates: {
|
||||||
getSettings: getApplicationSettings,
|
getSettings: getApplicationSettings,
|
||||||
updateSettings: updateApplicationSettings,
|
updateSettings: updateApplicationSettings,
|
||||||
@@ -1579,6 +1594,19 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByText('开发者预览 · OpenAI 兼容')
|
screen.getByText('开发者预览 · OpenAI 兼容')
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByRole('heading', { name: 'DSH 插件市场' })
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
/第三方插件的安装脚本、初始化代码及工具均以当前用户权限运行/
|
||||||
|
)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByRole('switch', {
|
||||||
|
name: '启用 DSH 插件市场'
|
||||||
|
})
|
||||||
|
).toBeChecked()
|
||||||
const harnessOverview = screen
|
const harnessOverview = screen
|
||||||
.getByText('GoodBuddy 内置 DeepSeek Harness')
|
.getByText('GoodBuddy 内置 DeepSeek Harness')
|
||||||
.closest<HTMLElement>('.runtime-overview')
|
.closest<HTMLElement>('.runtime-overview')
|
||||||
@@ -1607,7 +1635,7 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
fireEvent.click(screen.getByText('高级设置'))
|
fireEvent.click(screen.getByText('高级设置'))
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.getByText(
|
||||||
/始终使用 GoodBuddy 内置并固定版本的 Host/
|
/已启用的市场插件由 GoodBuddy 托管并随 Host 启动/
|
||||||
)
|
)
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
@@ -2838,7 +2866,9 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
fireEvent.click(screen.getByRole('tab', { name: 'Skills' }))
|
fireEvent.click(screen.getByRole('tab', { name: 'Skills' }))
|
||||||
expect(await screen.findByText('文档写作')).toBeInTheDocument()
|
expect(await screen.findByText('文档写作')).toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('支持直连模型、OpenCode 和 Continue')
|
screen.getByText(
|
||||||
|
'支持直连模型、OpenCode、Continue 和 DeepSeek Harness'
|
||||||
|
)
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/新导入的 Skill 默认启用/)
|
screen.getByText(/新导入的 Skill 默认启用/)
|
||||||
@@ -3046,10 +3076,12 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
within(mcpTabs).getByRole('tab', { name: '自定义 MCP' })
|
within(mcpTabs).getByRole('tab', { name: '自定义 MCP' })
|
||||||
)
|
)
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/自定义 MCP 可分配给直连模型或 DeepSeek Harness/)
|
screen.getByText(
|
||||||
|
/自定义 MCP 可分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent 或 DeepSeek Harness/
|
||||||
|
)
|
||||||
).toHaveTextContent('新建时默认分配给直连模型')
|
).toHaveTextContent('新建时默认分配给直连模型')
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/服务凭据不会进入 Harness Utility/)
|
screen.getByText(/服务地址、命令和凭据始终由 GoodBuddy 主进程保管/)
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText('尚未配置 MCP Server')
|
await screen.findByText('尚未配置 MCP Server')
|
||||||
@@ -3079,9 +3111,8 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
expect(
|
expect(
|
||||||
within(dialog).getByLabelText('DeepSeek Harness')
|
within(dialog).getByLabelText('DeepSeek Harness')
|
||||||
).not.toBeChecked()
|
).not.toBeChecked()
|
||||||
expect(
|
expect(within(dialog).getByLabelText('OpenCode')).not.toBeChecked()
|
||||||
within(dialog).queryByLabelText('OpenCode')
|
expect(within(dialog).getByLabelText('Continue')).not.toBeChecked()
|
||||||
).not.toBeInTheDocument()
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(within(dialog).getByLabelText('名称')).toHaveFocus()
|
expect(within(dialog).getByLabelText('名称')).toHaveFocus()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { PlatformFeaturesSettingsSection } from './PlatformFeaturesSettingsSecti
|
|||||||
import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
|
import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
|
||||||
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
|
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
|
||||||
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
|
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
|
||||||
|
import { DshMarketplaceSection } from './DshMarketplaceSection'
|
||||||
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
|
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
|
||||||
import {
|
import {
|
||||||
SettingsCategoryHeader,
|
SettingsCategoryHeader,
|
||||||
@@ -2192,6 +2193,9 @@ export function SettingsPanel({
|
|||||||
</button>
|
</button>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{agentRuntimeType === 'deepseek-harness' && (
|
||||||
|
<DshMarketplaceSection onNotify={onNotify} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export const integrations = {
|
|||||||
custom: 'Custom MCP'
|
custom: 'Custom MCP'
|
||||||
},
|
},
|
||||||
customNotice:
|
customNotice:
|
||||||
'Custom MCP can be assigned to direct models or DeepSeek Harness. New servers target direct models by default and load only in Execute mode. GoodBuddy proxies Harness tools in the main process, so server credentials never enter the Harness Utility.',
|
'Custom MCP can be assigned to direct models, GoodBuddy-managed OpenCode, Continue Agent, or DeepSeek Harness. New servers target direct models by default and load only in Execute mode. Agent runtimes receive only a request-scoped loopback capability; GoodBuddy keeps server addresses, commands, and credentials in the main process.',
|
||||||
securityNotice:
|
securityNotice:
|
||||||
'Built-in tools are provided by GoodBuddy and are not MCP servers. Custom MCP servers and tools run with the current user’s permissions, so add only trusted services. Remote access tokens are encrypted in secure system storage, and tool calls still require GoodBuddy approval.',
|
'Built-in tools are provided by GoodBuddy and are not MCP servers. Custom MCP servers and tools run with the current user’s permissions, so add only trusted services. Remote access tokens are encrypted in secure system storage, and tool calls still require GoodBuddy approval.',
|
||||||
computer: {
|
computer: {
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ export const settings = {
|
|||||||
skills: {
|
skills: {
|
||||||
label: 'Skills',
|
label: 'Skills',
|
||||||
navigationDescription: 'Built-in and custom capabilities',
|
navigationDescription: 'Built-in and custom capabilities',
|
||||||
description: 'Works with direct models, OpenCode, and Continue'
|
description:
|
||||||
|
'Works with direct models, OpenCode, Continue, and DeepSeek Harness'
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
label: 'MCP',
|
label: 'MCP',
|
||||||
@@ -246,7 +247,7 @@ export const settings = {
|
|||||||
title: 'DeepSeek Harness',
|
title: 'DeepSeek Harness',
|
||||||
previewDescription: 'Developer preview · OpenAI-compatible',
|
previewDescription: 'Developer preview · OpenAI-compatible',
|
||||||
description:
|
description:
|
||||||
'GoodBuddy maintains the fixed Host and control protocol internally and uses pinned Harness libraries underneath. Execute tool calls receive automatic one-time authorization, Ask remains read-only, and cancellation and workspace safety boundaries remain in place. It does not integrate with the DSH plugin or marketplace mechanisms.',
|
'GoodBuddy maintains the fixed Host and control protocol internally and uses pinned Harness libraries underneath. Ask limits model tool calls to read-only tools, while Execute can use every enabled tool and DSH plugin capability. Cancellation and workspace boundaries remain in place.',
|
||||||
managedSource:
|
managedSource:
|
||||||
'Administrator-provided OpenAI-compatible connection',
|
'Administrator-provided OpenAI-compatible connection',
|
||||||
connection: 'OpenAI-compatible model connection',
|
connection: 'OpenAI-compatible model connection',
|
||||||
@@ -255,7 +256,81 @@ export const settings = {
|
|||||||
connectionDescription:
|
connectionDescription:
|
||||||
'Choose a GoodBuddy model connection. It must use OpenAI Chat Completions with API-key authentication.',
|
'Choose a GoodBuddy model connection. It must use OpenAI Chat Completions with API-key authentication.',
|
||||||
advancedDescription:
|
advancedDescription:
|
||||||
'This Runtime always uses GoodBuddy’s bundled, version-pinned Host. It does not load external DSH plugins, marketplace packages, user profiles, or custom Hosts.'
|
'This Runtime always uses GoodBuddy’s bundled, version-pinned Host and does not load user profiles or custom Hosts. GoodBuddy manages enabled marketplace plugins and loads them with the Host.',
|
||||||
|
marketplace: {
|
||||||
|
title: 'DSH plugin marketplace',
|
||||||
|
previewDescription: 'Preview · public npm registry',
|
||||||
|
switch: {
|
||||||
|
aria: 'Enable the DSH plugin marketplace',
|
||||||
|
enabled: 'On',
|
||||||
|
disabled: 'Off'
|
||||||
|
},
|
||||||
|
disabledDescription:
|
||||||
|
'The plugin marketplace is off by default. Turn it on to connect to the public npm catalog and show its management interface. Turning off the marketplace does not disable or uninstall existing plugins.',
|
||||||
|
permissionNotice:
|
||||||
|
'Third-party install scripts, initialization code, and tools run with your user permissions. Ask limits the model from calling non-read-only tools, but cannot limit plugin initialization code. Execute can call every tool from enabled plugins. Install only packages you trust.',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
refreshAria: 'Refresh the DSH plugin marketplace',
|
||||||
|
searchLabel: 'Search plugins',
|
||||||
|
searchPlaceholder:
|
||||||
|
'Filter by name, package, description, or license',
|
||||||
|
retry: 'Try again',
|
||||||
|
loading: 'Loading the plugin catalog…',
|
||||||
|
catalogUnavailable:
|
||||||
|
'Could not refresh the npm plugin catalog: {{detail}}. Installed plugins remain manageable.',
|
||||||
|
results: 'Showing {{shown}} of {{total}} plugins',
|
||||||
|
noResults: 'No plugins match your search.',
|
||||||
|
empty: 'No DSH plugins were found in the public npm registry.',
|
||||||
|
refineSearch:
|
||||||
|
'Only the first {{count}} plugins are shown. Refine your search to see others.',
|
||||||
|
notInCatalog:
|
||||||
|
'This installed plugin is not currently in the npm marketplace catalog.',
|
||||||
|
installed: 'Installed',
|
||||||
|
enabled: 'Enabled',
|
||||||
|
disabled: 'Disabled',
|
||||||
|
enableAria: 'Enable {{name}}',
|
||||||
|
install: 'Install and enable',
|
||||||
|
update: 'Update to {{version}}',
|
||||||
|
installing: 'Installing…',
|
||||||
|
installConfirmationTitle: 'Install {{name}}',
|
||||||
|
installConfirmation:
|
||||||
|
'npm runs install scripts declared by this package and its dependencies. After installation, plugin initialization code runs when DeepSeek Harness starts.',
|
||||||
|
trustConfirmation:
|
||||||
|
'I trust {{package}} and understand that its code runs with my user permissions.',
|
||||||
|
confirmInstall: 'Confirm install',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
remove: 'Remove',
|
||||||
|
removeAria: 'Remove {{name}}',
|
||||||
|
confirmRemove: 'Confirm removal',
|
||||||
|
removeMessage:
|
||||||
|
'Remove {{name}} and its GoodBuddy-managed files?',
|
||||||
|
startupFailure:
|
||||||
|
'The plugin failed on its last startup and was disabled automatically. Check its configuration or version before enabling it again.',
|
||||||
|
configuration: {
|
||||||
|
open: 'Configure',
|
||||||
|
close: 'Close configuration',
|
||||||
|
label: '{{name}} configuration JSON',
|
||||||
|
help: 'Save a JSON object and restart the current Runtime to pass it to the plugin.',
|
||||||
|
save: 'Save configuration',
|
||||||
|
invalid: 'Configuration must be a valid JSON object.'
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
unavailable:
|
||||||
|
'The DSH plugin marketplace is unavailable in this version',
|
||||||
|
readFailed: 'Could not load the DSH plugin marketplace',
|
||||||
|
operationFailed: 'The DSH plugin operation failed'
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
marketplaceEnabled: 'Enabled the DSH plugin marketplace',
|
||||||
|
marketplaceDisabled: 'Disabled the DSH plugin marketplace',
|
||||||
|
installed: 'Installed and enabled {{name}}',
|
||||||
|
updated: 'Updated {{name}}',
|
||||||
|
enabled: 'Enabled {{name}}',
|
||||||
|
disabled: 'Disabled {{name}}',
|
||||||
|
configured: 'Saved the configuration for {{name}}',
|
||||||
|
removed: 'Removed {{name}}'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
documentParsing: {
|
documentParsing: {
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export const integrations = {
|
|||||||
custom: '自定义 MCP'
|
custom: '自定义 MCP'
|
||||||
},
|
},
|
||||||
customNotice:
|
customNotice:
|
||||||
'自定义 MCP 可分配给直连模型或 DeepSeek Harness,新建时默认分配给直连模型,并仅在 Execute 模式加载。Harness 工具由 GoodBuddy 主进程代理,服务凭据不会进入 Harness Utility。',
|
'自定义 MCP 可分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent 或 DeepSeek Harness,新建时默认分配给直连模型,并仅在 Execute 模式加载。Agent Runtime 只接收按请求签发的本机回环权限;服务地址、命令和凭据始终由 GoodBuddy 主进程保管。',
|
||||||
securityNotice:
|
securityNotice:
|
||||||
'内置工具由 GoodBuddy 提供,不属于 MCP Server。自定义 MCP Server 及其工具具有当前用户权限,请仅添加可信服务;远程访问令牌将由系统安全存储加密,工具调用前仍需 GoodBuddy 审批。',
|
'内置工具由 GoodBuddy 提供,不属于 MCP Server。自定义 MCP Server 及其工具具有当前用户权限,请仅添加可信服务;远程访问令牌将由系统安全存储加密,工具调用前仍需 GoodBuddy 审批。',
|
||||||
computer: {
|
computer: {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const settings = {
|
|||||||
skills: {
|
skills: {
|
||||||
label: 'Skills',
|
label: 'Skills',
|
||||||
navigationDescription: '内置与自定义能力',
|
navigationDescription: '内置与自定义能力',
|
||||||
description: '支持直连模型、OpenCode 和 Continue'
|
description: '支持直连模型、OpenCode、Continue 和 DeepSeek Harness'
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
label: 'MCP',
|
label: 'MCP',
|
||||||
@@ -223,14 +223,83 @@ export const settings = {
|
|||||||
title: 'DeepSeek Harness',
|
title: 'DeepSeek Harness',
|
||||||
previewDescription: '开发者预览 · OpenAI 兼容',
|
previewDescription: '开发者预览 · OpenAI 兼容',
|
||||||
description:
|
description:
|
||||||
'由 GoodBuddy 内部维护固定 Host 与控制协议,复用锁定的 Harness 底层库;Execute 工具调用自动单次授权,Ask 保持只读,并保留取消和工作区安全边界;不接入 DSH 插件或市场机制。',
|
'由 GoodBuddy 内部维护固定 Host 与控制协议,复用锁定的 Harness 底层库;Ask 仅允许模型调用只读工具,Execute 可调用全部已启用工具及 DSH 插件能力,并保留取消和工作区边界。',
|
||||||
managedSource: '管理员预置的 OpenAI 兼容连接',
|
managedSource: '管理员预置的 OpenAI 兼容连接',
|
||||||
connection: 'OpenAI 兼容模型连接',
|
connection: 'OpenAI 兼容模型连接',
|
||||||
connectionPlaceholder: '选择 OpenAI 兼容模型连接',
|
connectionPlaceholder: '选择 OpenAI 兼容模型连接',
|
||||||
connectionDescription:
|
connectionDescription:
|
||||||
'从 GoodBuddy 模型连接中选择;协议必须为 OpenAI Chat Completions,并使用 API Key。',
|
'从 GoodBuddy 模型连接中选择;协议必须为 OpenAI Chat Completions,并使用 API Key。',
|
||||||
advancedDescription:
|
advancedDescription:
|
||||||
'该 Runtime 始终使用 GoodBuddy 内置并固定版本的 Host,不加载外部 DSH 插件、市场包、用户 profile 或自定义 Host。'
|
'该 Runtime 始终使用 GoodBuddy 内置并固定版本的 Host,不加载用户 profile 或自定义 Host;已启用的市场插件由 GoodBuddy 托管并随 Host 启动。',
|
||||||
|
marketplace: {
|
||||||
|
title: 'DSH 插件市场',
|
||||||
|
previewDescription: '预览 · npm 公共仓库',
|
||||||
|
switch: {
|
||||||
|
aria: '启用 DSH 插件市场',
|
||||||
|
enabled: '已开启',
|
||||||
|
disabled: '已关闭'
|
||||||
|
},
|
||||||
|
disabledDescription:
|
||||||
|
'插件市场默认关闭。开启后才会连接公共 npm 目录并显示管理界面;关闭市场不会停用或卸载已有插件。',
|
||||||
|
permissionNotice:
|
||||||
|
'第三方插件的安装脚本、初始化代码及工具均以当前用户权限运行。Ask 只限制模型调用非只读工具,无法限制插件初始化代码;Execute 可调用已启用插件提供的全部工具。请仅安装可信包。',
|
||||||
|
refresh: '刷新',
|
||||||
|
refreshAria: '刷新 DSH 插件市场',
|
||||||
|
searchLabel: '搜索插件',
|
||||||
|
searchPlaceholder: '按名称、包名、描述或许可证筛选',
|
||||||
|
retry: '重试',
|
||||||
|
loading: '正在加载插件目录…',
|
||||||
|
catalogUnavailable:
|
||||||
|
'无法刷新 npm 插件目录:{{detail}}。已安装插件仍可管理。',
|
||||||
|
results: '显示 {{shown}} / {{total}} 个插件',
|
||||||
|
noResults: '没有匹配的插件。',
|
||||||
|
empty: 'npm 公共仓库中暂未找到 DSH 插件。',
|
||||||
|
refineSearch: '当前最多显示 {{count}} 个插件,请缩小搜索范围。',
|
||||||
|
notInCatalog: '此已安装插件目前不在 npm 市场目录中。',
|
||||||
|
installed: '已安装',
|
||||||
|
enabled: '已启用',
|
||||||
|
disabled: '已停用',
|
||||||
|
enableAria: '启用 {{name}}',
|
||||||
|
install: '安装并启用',
|
||||||
|
update: '更新到 {{version}}',
|
||||||
|
installing: '正在安装…',
|
||||||
|
installConfirmationTitle: '安装 {{name}}',
|
||||||
|
installConfirmation:
|
||||||
|
'npm 会运行该包及其依赖声明的安装脚本。安装后,插件初始化代码会随 DeepSeek Harness 启动。',
|
||||||
|
trustConfirmation:
|
||||||
|
'我信任 {{package}},并了解其代码将以当前用户权限运行。',
|
||||||
|
confirmInstall: '确认安装',
|
||||||
|
cancel: '取消',
|
||||||
|
remove: '移除',
|
||||||
|
removeAria: '移除 {{name}}',
|
||||||
|
confirmRemove: '确认移除',
|
||||||
|
removeMessage: '移除 {{name}} 及其由 GoodBuddy 托管的文件?',
|
||||||
|
startupFailure:
|
||||||
|
'插件上次启动失败,已自动停用。确认配置或版本后可重新启用。',
|
||||||
|
configuration: {
|
||||||
|
open: '配置',
|
||||||
|
close: '收起配置',
|
||||||
|
label: '{{name}} 配置 JSON',
|
||||||
|
help: '保存一个 JSON 对象并重启当前 Runtime,使配置传给插件。',
|
||||||
|
save: '保存配置',
|
||||||
|
invalid: '配置必须是有效的 JSON 对象。'
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
unavailable: '当前版本未提供 DSH 插件市场服务',
|
||||||
|
readFailed: '读取 DSH 插件市场失败',
|
||||||
|
operationFailed: 'DSH 插件操作失败'
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
marketplaceEnabled: '已开启 DSH 插件市场',
|
||||||
|
marketplaceDisabled: '已关闭 DSH 插件市场',
|
||||||
|
installed: '已安装并启用 {{name}}',
|
||||||
|
updated: '已更新 {{name}}',
|
||||||
|
enabled: '已启用 {{name}}',
|
||||||
|
disabled: '已停用 {{name}}',
|
||||||
|
configured: '已保存 {{name}} 的配置',
|
||||||
|
removed: '已移除 {{name}}'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
documentParsing: {
|
documentParsing: {
|
||||||
|
|||||||
@@ -5262,6 +5262,242 @@ button > svg {
|
|||||||
color: var(--text-secondary) !important;
|
color: var(--text-secondary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.runtime-extension-marketplace {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-marketplace__header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-marketplace__master-toggle {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-marketplace__search-input {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-marketplace__search-input > svg {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
color: var(--text-muted);
|
||||||
|
left: 11px;
|
||||||
|
pointer-events: none;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-marketplace__search-input > input {
|
||||||
|
padding-left: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-marketplace__load-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-marketplace__load-error > p {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-marketplace__list {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-card {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-card__header {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-card__header > div:first-child {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-card__header strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--font-body);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-card__header code {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-family-mono);
|
||||||
|
font-size: var(--font-caption);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-card__tags {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-card__tags span {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-caption);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-card > p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-body);
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-card__actions {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-card__actions > button,
|
||||||
|
.runtime-extension-card__actions > .danger-confirm,
|
||||||
|
.runtime-extension-configuration > button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-card__actions > .primary-button {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-card__toggle {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
border-top: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
|
font-size: var(--font-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-install-confirmation {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--warning-border);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--warning-subtle);
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-install-confirmation legend {
|
||||||
|
padding: 0 var(--space-1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--font-body);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-install-confirmation p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-caption);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-install-confirmation > label {
|
||||||
|
display: grid;
|
||||||
|
align-items: start;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-caption);
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: var(--space-2);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-install-confirmation > label input {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-install-confirmation > div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-configuration {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-configuration textarea {
|
||||||
|
min-height: 120px;
|
||||||
|
font-family: var(--font-family-mono);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-configuration > button {
|
||||||
|
width: fit-content;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.runtime-extension-marketplace
|
||||||
|
> .settings-section__title--actions {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-marketplace__header-actions {
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 26px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-card__header,
|
||||||
|
.runtime-extension-marketplace__load-error {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-card__tags {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-extension-card__actions > .primary-button {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
details.settings-section {
|
details.settings-section {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ import type {
|
|||||||
DocumentParsingTestPurpose
|
DocumentParsingTestPurpose
|
||||||
} from './document-parsing-contracts'
|
} from './document-parsing-contracts'
|
||||||
import type { SettingsWarning } from './settings-warning-contracts'
|
import type { SettingsWarning } from './settings-warning-contracts'
|
||||||
|
import type {
|
||||||
|
RuntimeExtensionAction,
|
||||||
|
RuntimeExtensionMarketplaceSnapshot
|
||||||
|
} from './runtime-extension-contracts'
|
||||||
import type {
|
import type {
|
||||||
KnowledgeChunkDeleteInput,
|
KnowledgeChunkDeleteInput,
|
||||||
KnowledgeChunkPage,
|
KnowledgeChunkPage,
|
||||||
@@ -1518,6 +1522,12 @@ export type DesktopApi = {
|
|||||||
profileId: string
|
profileId: string
|
||||||
) => Promise<CapabilitySnapshot>
|
) => Promise<CapabilitySnapshot>
|
||||||
}
|
}
|
||||||
|
runtimeExtensions: {
|
||||||
|
getSnapshot: () => Promise<RuntimeExtensionMarketplaceSnapshot>
|
||||||
|
apply: (
|
||||||
|
action: RuntimeExtensionAction
|
||||||
|
) => Promise<RuntimeExtensionMarketplaceSnapshot>
|
||||||
|
}
|
||||||
context: {
|
context: {
|
||||||
selectFiles: () => Promise<ContextAttachment[]>
|
selectFiles: () => Promise<ContextAttachment[]>
|
||||||
onFileSelectionProgress: (
|
onFileSelectionProgress: (
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ export const ipcChannels = {
|
|||||||
capabilitiesRenameBrowserProfile: 'capabilities:browser-profile:rename',
|
capabilitiesRenameBrowserProfile: 'capabilities:browser-profile:rename',
|
||||||
capabilitiesDefaultBrowserProfile: 'capabilities:browser-profile:default',
|
capabilitiesDefaultBrowserProfile: 'capabilities:browser-profile:default',
|
||||||
capabilitiesRemoveBrowserProfile: 'capabilities:browser-profile:remove',
|
capabilitiesRemoveBrowserProfile: 'capabilities:browser-profile:remove',
|
||||||
|
runtimeExtensionsSnapshot: 'runtime-extensions:snapshot',
|
||||||
|
runtimeExtensionsApply: 'runtime-extensions:apply',
|
||||||
contextSelectFiles: 'context:select-files',
|
contextSelectFiles: 'context:select-files',
|
||||||
contextFileSelectionProgress: 'context:file-selection-progress',
|
contextFileSelectionProgress: 'context:file-selection-progress',
|
||||||
contextAddPastedImage: 'context:add-pasted-image',
|
contextAddPastedImage: 'context:add-pasted-image',
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
runtimeExtensionActionSchema,
|
||||||
|
runtimeExtensionCatalogEntrySchema,
|
||||||
|
runtimeExtensionMarketplaceSnapshotSchema
|
||||||
|
} from './runtime-extension-contracts'
|
||||||
|
|
||||||
|
const catalogEntry = {
|
||||||
|
id: 'web-research',
|
||||||
|
package: {
|
||||||
|
name: '@goodbuddy/dsh-web-research',
|
||||||
|
version: '1.2.3'
|
||||||
|
},
|
||||||
|
displayName: 'Web research',
|
||||||
|
description: 'Researches public web pages.',
|
||||||
|
repository: 'https://example.com/goodbuddy/web-research',
|
||||||
|
license: 'MIT'
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runtime extension contracts', () => {
|
||||||
|
it('accepts the minimal catalog metadata', () => {
|
||||||
|
expect(runtimeExtensionCatalogEntrySchema.parse(catalogEntry)).toEqual(
|
||||||
|
catalogEntry
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires exact semantic versions', () => {
|
||||||
|
expect(
|
||||||
|
runtimeExtensionActionSchema.safeParse({
|
||||||
|
type: 'install',
|
||||||
|
extensionId: catalogEntry.id,
|
||||||
|
package: { ...catalogEntry.package, version: '^1.2.3' }
|
||||||
|
}).success
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects removed policy fields and rollback actions', () => {
|
||||||
|
expect(
|
||||||
|
runtimeExtensionCatalogEntrySchema.safeParse({
|
||||||
|
...catalogEntry,
|
||||||
|
permissions: []
|
||||||
|
}).success
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
runtimeExtensionActionSchema.safeParse({
|
||||||
|
type: 'rollback',
|
||||||
|
extensionId: catalogEntry.id
|
||||||
|
}).success
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('models snapshots with JSON-like configuration', () => {
|
||||||
|
const snapshot = {
|
||||||
|
marketplaceEnabled: true,
|
||||||
|
catalog: [catalogEntry],
|
||||||
|
installed: [
|
||||||
|
{
|
||||||
|
id: catalogEntry.id,
|
||||||
|
package: catalogEntry.package,
|
||||||
|
installedAt: '2026-08-16T00:00:00.000Z',
|
||||||
|
enabled: true,
|
||||||
|
integrity: `sha512-${Buffer.from('digest').toString(
|
||||||
|
'base64'
|
||||||
|
)}`,
|
||||||
|
configuration: {
|
||||||
|
resultLimit: 10,
|
||||||
|
filters: { domains: ['example.com'], exact: true },
|
||||||
|
optional: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
runtimeExtensionMarketplaceSnapshotSchema.parse(snapshot)
|
||||||
|
).toEqual(snapshot)
|
||||||
|
expect(
|
||||||
|
runtimeExtensionMarketplaceSnapshotSchema.safeParse({
|
||||||
|
...snapshot,
|
||||||
|
installed: [
|
||||||
|
{
|
||||||
|
...snapshot.installed[0],
|
||||||
|
entrypoint:
|
||||||
|
'C:\\Users\\tester\\runtime-extensions\\extensions\\web-research\\dist\\index.js'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}).success
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
runtimeExtensionMarketplaceSnapshotSchema.safeParse({
|
||||||
|
...snapshot,
|
||||||
|
warnings: []
|
||||||
|
}).success
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports only the explicit marketplace switch action', () => {
|
||||||
|
expect(
|
||||||
|
runtimeExtensionActionSchema.parse({
|
||||||
|
type: 'set-marketplace-enabled',
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
type: 'set-marketplace-enabled',
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
runtimeExtensionActionSchema.safeParse({
|
||||||
|
type: 'set-marketplace-enabled',
|
||||||
|
enabled: true,
|
||||||
|
extensionId: 'unexpected'
|
||||||
|
}).success
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bounds configuration size, depth, and collection width', () => {
|
||||||
|
let deeplyNested: Record<string, unknown> = {}
|
||||||
|
for (let depth = 0; depth < 18; depth += 1) {
|
||||||
|
deeplyNested = { nested: deeplyNested }
|
||||||
|
}
|
||||||
|
const action = {
|
||||||
|
type: 'configure',
|
||||||
|
extensionId: catalogEntry.id
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
runtimeExtensionActionSchema.safeParse({
|
||||||
|
...action,
|
||||||
|
configuration: { value: 'x'.repeat(65 * 1_024) }
|
||||||
|
}).success
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
runtimeExtensionActionSchema.safeParse({
|
||||||
|
...action,
|
||||||
|
configuration: deeplyNested
|
||||||
|
}).success
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
runtimeExtensionActionSchema.safeParse({
|
||||||
|
...action,
|
||||||
|
configuration: {
|
||||||
|
values: Array.from({ length: 257 }, () => true)
|
||||||
|
}
|
||||||
|
}).success
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const runtimeExtensionStartupFailureCode = 'startup-failed'
|
||||||
|
export const legacyRuntimeExtensionStartupFailure =
|
||||||
|
'Extension failed to start.'
|
||||||
|
|
||||||
|
export const runtimeExtensionIdSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(128)
|
||||||
|
.regex(/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/u)
|
||||||
|
|
||||||
|
export const runtimeExtensionPackageNameSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(214)
|
||||||
|
.regex(/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u)
|
||||||
|
|
||||||
|
export const runtimeExtensionVersionSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(64)
|
||||||
|
.regex(
|
||||||
|
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u
|
||||||
|
)
|
||||||
|
|
||||||
|
export const runtimeExtensionExactPackageSchema = z
|
||||||
|
.object({
|
||||||
|
name: runtimeExtensionPackageNameSchema,
|
||||||
|
version: runtimeExtensionVersionSchema
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const runtimeExtensionIntegritySchema = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(1_024)
|
||||||
|
.regex(
|
||||||
|
/^(?:sha(?:256|384|512)-[A-Za-z0-9+/]+={0,2})(?:\s+sha(?:256|384|512)-[A-Za-z0-9+/]+={0,2})*$/u
|
||||||
|
)
|
||||||
|
|
||||||
|
type JsonValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| JsonValue[]
|
||||||
|
| { [key: string]: JsonValue }
|
||||||
|
|
||||||
|
type JsonObject = { [key: string]: JsonValue }
|
||||||
|
|
||||||
|
const MAXIMUM_CONFIGURATION_BYTES = 64 * 1024
|
||||||
|
const MAXIMUM_CONFIGURATION_DEPTH = 16
|
||||||
|
const MAXIMUM_CONFIGURATION_NODES = 4_096
|
||||||
|
const MAXIMUM_CONFIGURATION_ENTRIES = 256
|
||||||
|
const MAXIMUM_CONFIGURATION_KEY_LENGTH = 256
|
||||||
|
const MAXIMUM_CONFIGURATION_STRING_LENGTH = 32_768
|
||||||
|
|
||||||
|
function isBoundedJsonConfiguration(value: unknown): value is JsonObject {
|
||||||
|
if (
|
||||||
|
!value ||
|
||||||
|
typeof value !== 'object' ||
|
||||||
|
Array.isArray(value)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const pending: Array<{ value: unknown; depth: number }> = [
|
||||||
|
{ value, depth: 0 }
|
||||||
|
]
|
||||||
|
const seen = new Set<object>()
|
||||||
|
let nodes = 0
|
||||||
|
while (pending.length > 0) {
|
||||||
|
const current = pending.pop()!
|
||||||
|
nodes += 1
|
||||||
|
if (
|
||||||
|
nodes > MAXIMUM_CONFIGURATION_NODES ||
|
||||||
|
current.depth > MAXIMUM_CONFIGURATION_DEPTH
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
current.value === null ||
|
||||||
|
typeof current.value === 'boolean'
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (typeof current.value === 'number') {
|
||||||
|
if (!Number.isFinite(current.value)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (typeof current.value === 'string') {
|
||||||
|
if (
|
||||||
|
current.value.length >
|
||||||
|
MAXIMUM_CONFIGURATION_STRING_LENGTH
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!current.value ||
|
||||||
|
typeof current.value !== 'object' ||
|
||||||
|
seen.has(current.value)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen.add(current.value)
|
||||||
|
if (Array.isArray(current.value)) {
|
||||||
|
if (
|
||||||
|
current.value.length > MAXIMUM_CONFIGURATION_ENTRIES
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for (const item of current.value) {
|
||||||
|
pending.push({
|
||||||
|
value: item,
|
||||||
|
depth: current.depth + 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const entries = Object.entries(current.value)
|
||||||
|
if (entries.length > MAXIMUM_CONFIGURATION_ENTRIES) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for (const [key, item] of entries) {
|
||||||
|
if (key.length > MAXIMUM_CONFIGURATION_KEY_LENGTH) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pending.push({
|
||||||
|
value: item,
|
||||||
|
depth: current.depth + 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
new TextEncoder().encode(JSON.stringify(value)).byteLength <=
|
||||||
|
MAXIMUM_CONFIGURATION_BYTES
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtimeExtensionConfigurationSchema =
|
||||||
|
z.custom<JsonObject>(
|
||||||
|
isBoundedJsonConfiguration,
|
||||||
|
'Extension configuration must be a bounded JSON object'
|
||||||
|
)
|
||||||
|
|
||||||
|
export const runtimeExtensionCatalogEntrySchema = z
|
||||||
|
.object({
|
||||||
|
id: runtimeExtensionIdSchema,
|
||||||
|
package: runtimeExtensionExactPackageSchema,
|
||||||
|
displayName: z.string().trim().min(1).max(128),
|
||||||
|
description: z.string().trim().min(1).max(2_000),
|
||||||
|
repository: z.string().url().max(2_048).optional(),
|
||||||
|
license: z.string().trim().min(1).max(128).optional()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const runtimeExtensionInstalledStateSchema = z
|
||||||
|
.object({
|
||||||
|
id: runtimeExtensionIdSchema,
|
||||||
|
package: runtimeExtensionExactPackageSchema,
|
||||||
|
entrypoint: z.string().min(1).max(32_768),
|
||||||
|
installedAt: z.string().datetime({ offset: true }),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
configuration: runtimeExtensionConfigurationSchema,
|
||||||
|
integrity: runtimeExtensionIntegritySchema.optional(),
|
||||||
|
lastError: z.string().trim().min(1).max(1_000).optional()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const runtimeExtensionMarketplaceInstalledStateSchema =
|
||||||
|
runtimeExtensionInstalledStateSchema.omit({
|
||||||
|
entrypoint: true
|
||||||
|
})
|
||||||
|
|
||||||
|
export const runtimeExtensionMarketplaceSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
marketplaceEnabled: z.boolean(),
|
||||||
|
catalog: z.array(runtimeExtensionCatalogEntrySchema),
|
||||||
|
installed: z.array(
|
||||||
|
runtimeExtensionMarketplaceInstalledStateSchema
|
||||||
|
),
|
||||||
|
catalogError: z.string().trim().min(1).max(1_000).optional()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const runtimeExtensionInstallActionSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal('install'),
|
||||||
|
extensionId: runtimeExtensionIdSchema,
|
||||||
|
package: runtimeExtensionExactPackageSchema
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const runtimeExtensionEnableActionSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal('set-enabled'),
|
||||||
|
extensionId: runtimeExtensionIdSchema,
|
||||||
|
enabled: z.boolean()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const runtimeExtensionRemoveActionSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal('remove'),
|
||||||
|
extensionId: runtimeExtensionIdSchema
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const runtimeExtensionConfigureActionSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal('configure'),
|
||||||
|
extensionId: runtimeExtensionIdSchema,
|
||||||
|
configuration: runtimeExtensionConfigurationSchema
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const runtimeExtensionMarketplaceEnableActionSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal('set-marketplace-enabled'),
|
||||||
|
enabled: z.boolean()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const runtimeExtensionActionSchema = z.discriminatedUnion('type', [
|
||||||
|
runtimeExtensionMarketplaceEnableActionSchema,
|
||||||
|
runtimeExtensionInstallActionSchema,
|
||||||
|
runtimeExtensionEnableActionSchema,
|
||||||
|
runtimeExtensionRemoveActionSchema,
|
||||||
|
runtimeExtensionConfigureActionSchema
|
||||||
|
])
|
||||||
|
|
||||||
|
export type RuntimeExtensionExactPackage = z.infer<
|
||||||
|
typeof runtimeExtensionExactPackageSchema
|
||||||
|
>
|
||||||
|
export type RuntimeExtensionCatalogEntry = z.infer<
|
||||||
|
typeof runtimeExtensionCatalogEntrySchema
|
||||||
|
>
|
||||||
|
export type RuntimeExtensionInstalledState = z.infer<
|
||||||
|
typeof runtimeExtensionInstalledStateSchema
|
||||||
|
>
|
||||||
|
export type RuntimeExtensionMarketplaceInstalledState = z.infer<
|
||||||
|
typeof runtimeExtensionMarketplaceInstalledStateSchema
|
||||||
|
>
|
||||||
|
export type RuntimeExtensionMarketplaceSnapshot = z.infer<
|
||||||
|
typeof runtimeExtensionMarketplaceSnapshotSchema
|
||||||
|
>
|
||||||
|
export type RuntimeExtensionAction = z.infer<
|
||||||
|
typeof runtimeExtensionActionSchema
|
||||||
|
>
|
||||||
|
export type RuntimeExtensionConfiguration = z.infer<
|
||||||
|
typeof runtimeExtensionConfigurationSchema
|
||||||
|
>
|
||||||
@@ -160,6 +160,27 @@ function portableDirectory(parent: string): string {
|
|||||||
join(directory, 'resources', 'runtimes', 'continue'),
|
join(directory, 'resources', 'runtimes', 'continue'),
|
||||||
{ recursive: true }
|
{ recursive: true }
|
||||||
)
|
)
|
||||||
|
mkdirSync(
|
||||||
|
join(
|
||||||
|
directory,
|
||||||
|
'resources',
|
||||||
|
'runtimes',
|
||||||
|
'npm',
|
||||||
|
'node_modules',
|
||||||
|
'graceful-fs'
|
||||||
|
),
|
||||||
|
{ recursive: true }
|
||||||
|
)
|
||||||
|
mkdirSync(
|
||||||
|
join(
|
||||||
|
directory,
|
||||||
|
'resources',
|
||||||
|
'runtimes',
|
||||||
|
'npm',
|
||||||
|
'bin'
|
||||||
|
),
|
||||||
|
{ recursive: true }
|
||||||
|
)
|
||||||
for (const [path, content] of [
|
for (const [path, content] of [
|
||||||
['GoodBuddy.exe', 'MZ'],
|
['GoodBuddy.exe', 'MZ'],
|
||||||
['resources/app.asar', 'asar'],
|
['resources/app.asar', 'asar'],
|
||||||
@@ -167,7 +188,19 @@ function portableDirectory(parent: string): string {
|
|||||||
['resources/icon.ico', 'icon'],
|
['resources/icon.ico', 'icon'],
|
||||||
['resources/tray-icon.png', 'tray'],
|
['resources/tray-icon.png', 'tray'],
|
||||||
['resources/runtimes/opencode/opencode.exe', 'MZ'],
|
['resources/runtimes/opencode/opencode.exe', 'MZ'],
|
||||||
['resources/runtimes/continue/package.json', '{}']
|
['resources/runtimes/continue/package.json', '{}'],
|
||||||
|
[
|
||||||
|
'resources/runtimes/npm/bin/npm-cli.js',
|
||||||
|
'require("../lib/cli.js")'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'resources/runtimes/npm/package.json',
|
||||||
|
'{"version":"11.19.0"}'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'resources/runtimes/npm/node_modules/graceful-fs/package.json',
|
||||||
|
'{"name":"graceful-fs"}'
|
||||||
|
]
|
||||||
] satisfies Array<[string, string]>) {
|
] satisfies Array<[string, string]>) {
|
||||||
writeFileSync(join(directory, ...path.split('/')), content)
|
writeFileSync(join(directory, ...path.split('/')), content)
|
||||||
}
|
}
|
||||||
@@ -300,6 +333,9 @@ describe('release build arguments', () => {
|
|||||||
'node_modules/@koromix/koffi-*/**/*'
|
'node_modules/@koromix/koffi-*/**/*'
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
|
expect(packageJson.build.asarUnpack).not.toContain(
|
||||||
|
'node_modules/npm/**/*'
|
||||||
|
)
|
||||||
expect(packageJson.optionalDependencies).toEqual({
|
expect(packageJson.optionalDependencies).toEqual({
|
||||||
'@koromix/koffi-darwin-arm64': '3.1.4',
|
'@koromix/koffi-darwin-arm64': '3.1.4',
|
||||||
'@koromix/koffi-darwin-x64': '3.1.4',
|
'@koromix/koffi-darwin-x64': '3.1.4',
|
||||||
@@ -385,7 +421,11 @@ describe('release build arguments', () => {
|
|||||||
const packageJson = require('../package.json') as {
|
const packageJson = require('../package.json') as {
|
||||||
build: {
|
build: {
|
||||||
files: string[]
|
files: string[]
|
||||||
extraResources: Array<{ from: string }>
|
extraResources: Array<{
|
||||||
|
from: string
|
||||||
|
to?: string
|
||||||
|
filter?: string[]
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const resourceSources = packageJson.build.extraResources.map(
|
const resourceSources = packageJson.build.extraResources.map(
|
||||||
@@ -394,9 +434,15 @@ describe('release build arguments', () => {
|
|||||||
|
|
||||||
expect(packageJson.build.files).toEqual([
|
expect(packageJson.build.files).toEqual([
|
||||||
'out/**/*',
|
'out/**/*',
|
||||||
'package.json'
|
'package.json',
|
||||||
|
'!node_modules/npm{,/**/*}'
|
||||||
])
|
])
|
||||||
expect(resourceSources).toContain('resources/skills')
|
expect(resourceSources).toContain('resources/skills')
|
||||||
|
expect(packageJson.build.extraResources).toContainEqual({
|
||||||
|
from: 'node_modules',
|
||||||
|
to: 'runtimes',
|
||||||
|
filter: ['npm{,/**/*}']
|
||||||
|
})
|
||||||
expect(
|
expect(
|
||||||
resourceSources.some((source) => source.startsWith('tests/'))
|
resourceSources.some((source) => source.startsWith('tests/'))
|
||||||
).toBe(false)
|
).toBe(false)
|
||||||
|
|||||||
Reference in New Issue
Block a user