diff --git a/build/build-release.cjs b/build/build-release.cjs index 0e2dee5f..2a88cc87 100644 --- a/build/build-release.cjs +++ b/build/build-release.cjs @@ -88,9 +88,11 @@ const portableRequiredFiles = [ 'resources/release-notes.json', 'resources/icon.ico', 'resources/tray-icon.png', + 'resources/tool-environment/managed-python-artifacts.json', 'resources/runtimes/opencode/opencode.exe', 'resources/runtimes/continue/package.json', 'resources/runtimes/npm/bin/npm-cli.js', + 'resources/runtimes/npm/bin/npx-cli.js', 'resources/runtimes/npm/package.json', 'resources/runtimes/npm/node_modules/graceful-fs/package.json' ] @@ -894,6 +896,10 @@ function verifyHarnessPackage( join(npmRoot, 'bin', 'npm-cli.js'), 'DSH 插件安装 npm CLI' ) + assertFile( + join(npmRoot, 'bin', 'npx-cli.js'), + 'DSH 插件安装 npx CLI' + ) if ( !Array.isArray(npmManifest.bundleDependencies) || npmManifest.bundleDependencies.length === 0 @@ -1053,6 +1059,14 @@ function verifyUnpackedOutput(directory, options) { assertFile(applicationExecutable, '应用主程序') assertFile(join(resources, 'app.asar'), '应用 ASAR') assertFile(join(resources, 'release-notes.json'), '版本更新说明') + assertFile( + join( + resources, + 'tool-environment', + 'managed-python-artifacts.json' + ), + '托管 Python 产物目录' + ) assertFile( join(resources, 'agent-release-keys.json'), 'Agent 受信任签名密钥' @@ -1080,6 +1094,16 @@ function verifyUnpackedOutput(directory, options) { ), 'DSH 插件安装 npm Runtime' ) + assertFile( + join( + resources, + 'runtimes', + 'npm', + 'bin', + 'npx-cli.js' + ), + 'DSH 插件安装 npx Runtime' + ) verifyHarnessPackage(resources, options) for (const [filePath, label] of [ [applicationExecutable, '应用主程序'], diff --git a/docs/features/local-tool-environment/logic-design.md b/docs/features/local-tool-environment/logic-design.md index d0319ac4..be2c9534 100644 --- a/docs/features/local-tool-environment/logic-design.md +++ b/docs/features/local-tool-environment/logic-design.md @@ -4,9 +4,9 @@ | 项目 | 内容 | | --- | --- | -| 状态 | 计划 | -| 版本 | 0.1 | -| 日期 | 2026-09-01 | +| 状态 | 已实施,验收中 | +| 版本 | 0.2 | +| 日期 | 2026-09-02 | | 关联 PRD | [工具执行环境 PRD](./prd.md) | | 关联场景 | [工具执行环境 User Stories](./user-stories.md) | diff --git a/docs/features/local-tool-environment/prd.md b/docs/features/local-tool-environment/prd.md index da5f68ff..c8a7e67f 100644 --- a/docs/features/local-tool-environment/prd.md +++ b/docs/features/local-tool-environment/prd.md @@ -4,9 +4,9 @@ | 项目 | 内容 | | --- | --- | -| 状态 | 计划 | -| 版本 | 0.1 | -| 日期 | 2026-09-01 | +| 状态 | 已批准并实施,验收中 | +| 版本 | 0.2 | +| 日期 | 2026-09-02 | | 适用产品 | GoodBuddy 桌面端 | | 目标平台 | Windows、macOS、Linux,x64 与 arm64 | | 关联文档 | [功能入口](./README.md)、[User Stories](./user-stories.md)、[功能逻辑](./logic-design.md)、[UI 设计](./ui-design.md)、[技术设计](./technical-design.md)、[功能进度](./progress.md) | diff --git a/docs/features/local-tool-environment/progress.md b/docs/features/local-tool-environment/progress.md index 053433eb..4ea5094c 100644 --- a/docs/features/local-tool-environment/progress.md +++ b/docs/features/local-tool-environment/progress.md @@ -5,107 +5,96 @@ | 项目 | 内容 | | --- | --- | | 文档类型 | 功能进度 | -| 状态 | 计划中 | -| 版本 | 0.1 | -| 日期 | 2026-09-01 | +| 状态 | 已实施,跨平台验收中 | +| 版本 | 0.3 | +| 日期 | 2026-09-02 | | 关联入口 | [工具执行环境](./README.md) | ## 当前结论 -设置菜单结构第一阶段已经进入源码:原 Skills/MCP 一级分类合并为“能力与工具”,内部 -显示两个水平 Tab。工具执行环境、Node/Python 和下载源尚未实施。 +本机工具执行环境的生产链路已经进入源码。“能力与工具”现在显示 `Skills`、`MCP`、 +`工具执行环境` 三个真实 Tab;Main 统一解析托管或自定义 Node/Python,并把不可变 PATH +快照应用到新启动的本机 Runtime 与 stdio MCP。Windows x64 已完成真实托管 Node 和原生 +地址托管 Python 安装验证。 + +该实现尚不能视为六平台发布验收完成:OSS 镜像对象当前公开回读为 404,Windows ARM64、 +macOS 和 Linux 工件仍需原生 CI 验证,第三方许可证资源也尚未随包落地。 ## 已完成 -- [x] 明确功能只服务本机 SKILL 和本机 stdio MCP。 -- [x] 明确远程 Host 不接收工具执行环境、SKILL 或 MCP。 -- [x] 将来源收敛为“GoodBuddy 托管”和“自定义环境”。 -- [x] 明确 Node、npm、npx 同源,Python、python3、pip 同源。 -- [x] 区分工具环境就绪与能力依赖就绪。 -- [x] 定义现有用户解释器的一次性兼容迁移。 -- [x] 定义状态机、来源和命令决策表、失败恢复与生效时机。 -- [x] 定义受管工具外部工件的原生地址/OSS 镜像规则。 -- [x] 将 Skills、MCP、工具执行环境收敛到“能力与工具”一级分类和三个水平 Tab。 -- [x] 实现“能力与工具”一级菜单及 `Skills`、`MCP` 两个水平 Tab。 -- [x] 移除原 Skills/MCP 一级入口,并保留原有业务功能和 MCP 内层页签。 -- [x] 未提前显示“工具执行环境”禁用项或占位页。 -- [x] 定义设置页结构、状态、文案和无障碍要求。 -- [x] 定义 Main 服务、shim、探测、诊断、PATH 注入和 Python 生命周期。 -- [x] 远程主机设置副标题已说明本机 Skills 和 MCP 不作用于远程主机。 -- [x] 将内置 Skill 顶层目录从 14 个收敛为 5 个,九种产品营销配方改为 - `product-marketing` 包内按需工作流。 -- [x] 通用文档工具默认启用,三个产品营销 Skill 默认停用,同时保留用户显式保存状态。 +### 设置、迁移和界面 -## 待实施 +- [x] Shared 严格设置、候选、诊断、进度、IPC 和 Preload 契约。 +- [x] Application Settings v10;全新用户默认托管环境和原生地址。 +- [x] v1-v9 分别执行真实 Node/Python 3 探测的一次性迁移;无有效候选时选择托管环境。 +- [x] PATH 和已保存自定义路径候选的规范化、去重、版本及架构诊断。 +- [x] 自定义解释器保存前真实验证;无效新选择不持久化,已保存失效路径不回退。 +- [x] “能力与工具”统一入口及三个水平 Tab,MCP 原内层页签保持不变。 +- [x] 工具下载源、Node.js、Python、候选、诊断、安装进度、取消和删除界面。 +- [x] 使用 `PageTabs`、应用通知、就地操作错误、删除确认和具名进度条。 -### 阶段 1:设置、探测和界面 +### 托管 Node 与进程注入 -- [ ] Shared 设置、状态、操作和 IPC schema。 -- [ ] 旧用户迁移。 -- [ ] Node/Python 候选探测。 -- [ ] 完整环境诊断。 -- [x] “能力与工具”统一设置分类及 Skills/MCP 水平 Tab。 -- [ ] 工具执行环境完成后追加第三个水平 Tab。 -- [ ] 工具下载源、Node.js、Python 和诊断卡片。 +- [x] 使用 Electron-as-Node 提供 `node`,使用同一 Node 和打包 CLI 提供 `npm`、`npx`。 +- [x] 自定义 Node 只使用同安装位置且验证成功的 npm/npx,不与托管 CLI 混用。 +- [x] 每次配置生成不可变 shim 目录;已有进程继续持有原 PATH,新进程读取新快照。 +- [x] OpenCode、Continue、DeepSeek Harness 和本机通用/精选 stdio MCP 接收工具 PATH。 +- [x] 工具 PATH 不修改 `process.env`,不进入普通终端或远程 Host。 +- [x] 解释器探针使用过滤后的环境,不接收模型凭据、GoodBuddy 或 Factory 内部变量。 -### 阶段 2:托管 Node +### 托管 Python -- [ ] 提取并复用 DSH Node shim。 -- [ ] 提供 node/npm/npx。 -- [ ] 注入 OpenCode、Continue、DeepSeek Harness。 -- [ ] 注入 Main 和 Runtime 的 stdio MCP。 -- [ ] 安装包真实 Node、npm、npx smoke。 +- [x] 锁定 CPython 3.13.15 的 Windows/macOS/Linux x64/ARM64 六平台目录、大小和 SHA-256。 +- [x] 用户选择的原生地址或 OSS 镜像在任务开始时冻结,同一次任务不回退。 +- [x] HTTPS、重定向 Host、大小、SHA-256、取消和部分文件清理。 +- [x] NuGet ZIP 与 Astral TAR 的 payload 限定、路径穿越防护、展开上限和安全链接处理。 +- [x] GoodBuddy-owned 暂存安装、发布、失败清理、更新保留和删除。 +- [x] `python`、`python3`、`pip` 同源,`pip` 固定使用所选 Python 的 `-m pip`。 +- [x] 发布前真实验证 Python 3.13.15、架构、标准库、SSL、pip,并创建和执行临时 venv。 +- [x] 取消恢复原状态且不显示为安装失败;退出时取消并等待安装清理。 -### 阶段 3:托管 Python +## 待完成验收 -- [ ] 选型并锁定六平台 CPython 工件。 -- [ ] 完成许可证和下载目录。 -- [ ] 为每个平台工件固定原生 Target 和字节相同的 OSS Target。 -- [ ] 下载、校验、安装、更新、取消和删除。 -- [ ] 提供 python/python3/pip。 -- [ ] Python 标准库、SSL、pip、venv 真实诊断。 - -### 阶段 4:端到端验收 - -- [ ] Node SKILL 实际成果。 -- [ ] Python SKILL 实际成果。 -- [ ] Node stdio MCP 实际调用。 -- [ ] Python stdio MCP 实际调用。 -- [ ] 自定义 Node/Python 路径。 -- [ ] Ask/Execute、Runtime 重建、取消、退出。 -- [ ] 六平台 CI、许可证和生产构建。 +- [ ] 发布六个平台/架构的字节完全相同 OSS 镜像对象并公开回读校验。 +- [ ] 将 CPython、PSF 和 python-build-standalone 适用许可证资源纳入发行包及构建校验。 +- [ ] Windows ARM64、macOS x64/ARM64、Linux x64/ARM64 原生 CI 安装、SSL、pip、venv 探针。 +- [ ] 最低 Windows/macOS/glibc 支持矩阵和 Windows NuGet 签名核验。 +- [ ] 真实 JavaScript/Python SKILL 成果及 Node/Python stdio MCP 调用。 +- [ ] 自定义 Node/Python 在产品 UI 中的真实选择和任务执行。 +- [ ] 更新或删除托管 Python 时,使用现有本机 Runtime/MCP 生命周期协调受影响进程。 ## 当前阻塞项 -- 尚未完成六平台可再分发 CPython 工件选型,因此托管 Python 版本、体积和下载摘要未锁定。 -- 工具执行环境的 Node/Python、下载源、诊断和进程注入尚未开始源码接入。 +- `https://goodbuddy.oss-cn-beijing.aliyuncs.com/tool-artifacts/...` 的 Windows x64 固定对象于 + 2026-09-02 返回 HTTP 404。发布对象需要发行基础设施的短期 OSS 凭据,当前会话没有 + 外部发布授权或凭据,因此 OSS 选项不能标记为生产可用。 +- 当前机器只能原生验证 Windows x64。其余五个平台必须由对应原生 CI 执行,不能用本机 + mock 代替。 +- 许可证文件和来源清单尚未进入 `resources/tool-environment`,发行合规验收未完成。 ## 已验证证据 -### 2026-09-02 +### 2026-09-02 完整实现 - `npm run typecheck` 通过。 - `npm run lint` 通过。 -- `npm run build` 通过。 -- `npm test` 通过:`3412` 通过、`50` 跳过。 -- Skill 聚焦测试通过:Capability/Bundled Skills `39/39`,产品营销路由 `10/10`。 -- `npm test -- src/renderer/src/SettingsPanel.test.tsx` 通过:`80/80`。 -- 设置页测试确认左侧不再显示独立 Skills/MCP,统一入口默认打开 Skills,MCP 原内层页签 - 保持可用,且不显示工具执行环境占位。 -- 修复能力与工具 Tab 包装层在可滚动设置布局中被 Flexbox 压缩到零高度的问题,并增加与 - 平台功能相同的 `flex: 0 0 auto` CSS 回归测试;聚焦测试 `110/110` 通过。 -- Electron 自动化检查被本机已运行正式版的单实例锁和隔离开发版原生启动错误阻塞;未停止 - 或修改正式版进程,已清理本次隔离进程和临时用户数据。 +- `npm run build` 通过,Main、Preload 和 Renderer 生产 bundle 成功。 +- 工具环境聚焦测试通过:契约、设置迁移、解析/shim、服务、下载、解压、安装、IPC、 + Preload、Runtime/MCP 注入、Renderer 和发行资源校验。 +- Windows x64 真实托管 Node:Electron Node `24.18.0`、npm `11.19.0`、npx `11.19.0`。 +- `GOODBUDDY_RUN_LIVE_TOOL_ENVIRONMENT=1` 真实下载官方 NuGet、校验摘要、解压、安装并 + 验证 CPython `3.13.15`、SSL、pip 和 venv,`1/1` 通过;临时安装已清理。 +- 隔离 Electron 生产 bundle 自动化确认三个 Tab 均可见;“诊断全部”显示托管 Node + `24.18.0`、npm/npx `11.19.0`。隔离进程和用户数据已清理。 +- 第一次全量测试发现 3 个确定性夹具/兼容失败,修复后聚焦复跑 `25/25` 通过。第二次暴露 + 2 个 `App.test.tsx` 设置导航时序问题,修正为按可访问名称点击并等待设置中心。 +- 最终 `npm test` 通过:`3465` 通过、`51` 跳过;完整 `App.test.tsx` 为 `157/157` 通过。 +- 原生地址范围请求返回 HTTP 206;对应 OSS 镜像返回 HTTP 404,已列为外部阻塞。 -### 2026-08-31 +### 2026-09-02 菜单阶段 -- 确认安装包携带 Electron Node 能力和锁定 npm CLI,但未向通用 SKILL/MCP 提供 `node` - 命令。 -- 确认 DSH 插件安装器已有 `ELECTRON_RUN_AS_NODE=1` shim,可作为复用基线。 -- 确认远程 Agent ACP Session 使用空 MCP Server 列表,本机 SKILL/MCP 不进入远程路径。 -- 远程主机设置文案聚焦测试通过:`80/80`。 -- 当时完整类型检查通过;完整测试和 Lint 被并行开发中的 `RightAssistantSidebar` 变更 - 阻塞,不作为本功能实现验证。 +- 完成“能力与工具”一级菜单及 Skills/MCP Tab,并修复可滚动 Flex 布局将页签压缩为零 + 高度的问题;加入 `flex: 0 0 auto` 回归约束。 ## 进度维护要求 diff --git a/docs/features/local-tool-environment/technical-design.md b/docs/features/local-tool-environment/technical-design.md index a09d9e01..a9c2c349 100644 --- a/docs/features/local-tool-environment/technical-design.md +++ b/docs/features/local-tool-environment/technical-design.md @@ -4,9 +4,9 @@ | 项目 | 内容 | | --- | --- | -| 状态 | 计划 | -| 版本 | 0.1 | -| 日期 | 2026-09-01 | +| 状态 | 已实施,跨平台验收中 | +| 版本 | 0.2 | +| 日期 | 2026-09-02 | | 关联 PRD | [工具执行环境 PRD](./prd.md) | | 功能逻辑 | [工具执行环境功能逻辑设计](./logic-design.md) | | 相关架构 | [DeepSeek Harness Runtime 设计](../deepseek-harness/technical-design.md)、[SSH 远程主机与 GoodBuddy Agent 实现说明](../remote-host/technical-design.md) | diff --git a/docs/features/local-tool-environment/ui-design.md b/docs/features/local-tool-environment/ui-design.md index 78e098ae..080aa9f8 100644 --- a/docs/features/local-tool-environment/ui-design.md +++ b/docs/features/local-tool-environment/ui-design.md @@ -4,9 +4,9 @@ | 项目 | 内容 | | --- | --- | -| 状态 | 计划 | -| 版本 | 0.1 | -| 日期 | 2026-09-01 | +| 状态 | 已实施 | +| 版本 | 0.2 | +| 日期 | 2026-09-02 | | 关联 PRD | [工具执行环境 PRD](./prd.md) | | 功能逻辑 | [工具执行环境功能逻辑设计](./logic-design.md) | | 全局规范 | [GoodBuddy 统一界面设计系统](../../../UI-DESIGN.md) | diff --git a/docs/features/local-tool-environment/user-stories.md b/docs/features/local-tool-environment/user-stories.md index 56c30eb1..f4be6622 100644 --- a/docs/features/local-tool-environment/user-stories.md +++ b/docs/features/local-tool-environment/user-stories.md @@ -4,9 +4,9 @@ | 项目 | 内容 | | --- | --- | -| 状态 | 计划 | -| 版本 | 0.2 | -| 日期 | 2026-09-01 | +| 状态 | 已实施,验收中 | +| 版本 | 0.3 | +| 日期 | 2026-09-02 | | 关联 PRD | [工具执行环境 PRD](./prd.md) | | 关联逻辑 | [工具执行环境功能逻辑设计](./logic-design.md) | diff --git a/package.json b/package.json index 8ed62167..5c705038 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,10 @@ "from": "resources/release-notes.json", "to": "release-notes.json" }, + { + "from": "resources/tool-environment/managed-python-artifacts.json", + "to": "tool-environment/managed-python-artifacts.json" + }, { "from": "agent-runtime-lock.json", "to": "agent-runtime-lock.json" diff --git a/resources/tool-environment/managed-python-artifacts.json b/resources/tool-environment/managed-python-artifacts.json new file mode 100644 index 00000000..6c408a1b --- /dev/null +++ b/resources/tool-environment/managed-python-artifacts.json @@ -0,0 +1,72 @@ +{ + "formatVersion": 1, + "pythonVersion": "3.13.15", + "artifacts": [ + { + "platform": "win32", + "arch": "x64", + "archiveFormat": "nuget-zip", + "payloadRoot": "tools", + "fileName": "python.3.13.15.nupkg", + "size": 14391248, + "sha256": "05357887df50d3153efc681bdf432c321d3e2f9ce5788f99f4515b27e8fda0ac", + "nativeUrl": "https://api.nuget.org/v3-flatcontainer/python/3.13.15/python.3.13.15.nupkg", + "ossUrl": "https://goodbuddy.oss-cn-beijing.aliyuncs.com/tool-artifacts/python/3.13.15/05357887df50d3153efc681bdf432c321d3e2f9ce5788f99f4515b27e8fda0ac/python.3.13.15.nupkg" + }, + { + "platform": "win32", + "arch": "arm64", + "archiveFormat": "nuget-zip", + "payloadRoot": "tools", + "fileName": "pythonarm64.3.13.15.nupkg", + "size": 13762836, + "sha256": "3c1b1fdf56adc14634165df922d447520aefdc4a8411a34c34d8a062a4edf494", + "nativeUrl": "https://api.nuget.org/v3-flatcontainer/pythonarm64/3.13.15/pythonarm64.3.13.15.nupkg", + "ossUrl": "https://goodbuddy.oss-cn-beijing.aliyuncs.com/tool-artifacts/python/3.13.15/3c1b1fdf56adc14634165df922d447520aefdc4a8411a34c34d8a062a4edf494/pythonarm64.3.13.15.nupkg" + }, + { + "platform": "darwin", + "arch": "x64", + "archiveFormat": "tar.gz", + "payloadRoot": "python", + "fileName": "cpython-3.13.15+20260825-x86_64-apple-darwin-install_only_stripped.tar.gz", + "size": 24921256, + "sha256": "d33d61f7f4982c94216e14a43599c75657b7d0839277fc72bc6dbac53e8229bc", + "nativeUrl": "https://github.com/astral-sh/python-build-standalone/releases/download/20260825/cpython-3.13.15%2B20260825-x86_64-apple-darwin-install_only_stripped.tar.gz", + "ossUrl": "https://goodbuddy.oss-cn-beijing.aliyuncs.com/tool-artifacts/python/3.13.15/d33d61f7f4982c94216e14a43599c75657b7d0839277fc72bc6dbac53e8229bc/cpython-3.13.15%2B20260825-x86_64-apple-darwin-install_only_stripped.tar.gz" + }, + { + "platform": "darwin", + "arch": "arm64", + "archiveFormat": "tar.gz", + "payloadRoot": "python", + "fileName": "cpython-3.13.15+20260825-aarch64-apple-darwin-install_only_stripped.tar.gz", + "size": 25140257, + "sha256": "149038dd0c194c25d4616d7e42a35f67f2edee96412788f74115819b6a4c8548", + "nativeUrl": "https://github.com/astral-sh/python-build-standalone/releases/download/20260825/cpython-3.13.15%2B20260825-aarch64-apple-darwin-install_only_stripped.tar.gz", + "ossUrl": "https://goodbuddy.oss-cn-beijing.aliyuncs.com/tool-artifacts/python/3.13.15/149038dd0c194c25d4616d7e42a35f67f2edee96412788f74115819b6a4c8548/cpython-3.13.15%2B20260825-aarch64-apple-darwin-install_only_stripped.tar.gz" + }, + { + "platform": "linux", + "arch": "x64", + "archiveFormat": "tar.gz", + "payloadRoot": "python", + "fileName": "cpython-3.13.15+20260825-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz", + "size": 34813635, + "sha256": "8af9a8214c71b2dd698005e39fab87aad02a994330508857da4e6d1ba7e6ddb6", + "nativeUrl": "https://github.com/astral-sh/python-build-standalone/releases/download/20260825/cpython-3.13.15%2B20260825-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz", + "ossUrl": "https://goodbuddy.oss-cn-beijing.aliyuncs.com/tool-artifacts/python/3.13.15/8af9a8214c71b2dd698005e39fab87aad02a994330508857da4e6d1ba7e6ddb6/cpython-3.13.15%2B20260825-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" + }, + { + "platform": "linux", + "arch": "arm64", + "archiveFormat": "tar.gz", + "payloadRoot": "python", + "fileName": "cpython-3.13.15+20260825-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz", + "size": 29222461, + "sha256": "e5d0df1a6070a8614d808496e5ea28c727480e40ffcce1a94697a067f1690aa8", + "nativeUrl": "https://github.com/astral-sh/python-build-standalone/releases/download/20260825/cpython-3.13.15%2B20260825-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz", + "ossUrl": "https://goodbuddy.oss-cn-beijing.aliyuncs.com/tool-artifacts/python/3.13.15/e5d0df1a6070a8614d808496e5ea28c727480e40ffcce1a94697a067f1690aa8/cpython-3.13.15%2B20260825-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" + } + ] +} diff --git a/src/main/agent/continue-host-adapter.test.ts b/src/main/agent/continue-host-adapter.test.ts index 97cdcc82..00141543 100644 --- a/src/main/agent/continue-host-adapter.test.ts +++ b/src/main/agent/continue-host-adapter.test.ts @@ -397,6 +397,13 @@ describe('ContinueHostAdapter', () => { let isolatedGlobalDirectory = '' let registeredSkill = '' let registeredSkillFile = '' + const launchEnvironmentProvider = vi.fn(() => + Object.freeze({ + PATH: 'C:\\GoodBuddy\\tools;C:\\System', + OPENAI_API_KEY: 'must-not-leak', + ELECTRON_RUN_AS_NODE: '1' + }) + ) const launchHost: ContinueHostLauncher = ( entryPath, args, @@ -500,6 +507,7 @@ describe('ContinueHostAdapter', () => { cacheRoot: distribution.cacheRoot, trustedBundleHashes: [distribution.sourceHash], launchHost, + launchEnvironmentProvider, mode: 'chat', skillPackages: [ { @@ -556,6 +564,11 @@ describe('ContinueHostAdapter', () => { expect(launch?.env.GOODBUDDY_CONTINUE_HOST_TOKEN).toEqual( expect.any(String) ) + expect(launchEnvironmentProvider).toHaveBeenCalledOnce() + expect(launch?.env.PATH).toBe( + 'C:\\GoodBuddy\\tools;C:\\System' + ) + expect(launch?.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE') expect(launch?.env).toMatchObject({ CONTINUE_CLI_AUTO_UPDATED: '1', CONTINUE_CLI_ENABLE_TELEMETRY: '0', diff --git a/src/main/agent/continue-host-adapter.ts b/src/main/agent/continue-host-adapter.ts index 3d43e19c..7368b8be 100644 --- a/src/main/agent/continue-host-adapter.ts +++ b/src/main/agent/continue-host-adapter.ts @@ -39,10 +39,12 @@ import type { ResolvedModelProfile } from '../runtime-settings-store' import type { RuntimeSkillPackage } from '../capabilities/capability-service' import { getAvailableLoopbackPort } from './loopback-port' import { + applyLaunchEnvironmentPath, buildExplicitProfileRuntimeEnvironment, buildRuntimeEnvironment, runtimePrivacyEnvironment } from './process-environment' +import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider' import { createAnthropicApiBaseUrl } from './anthropic-endpoint' import { createOpenAIApiBaseUrl } from './openai-endpoint' import { @@ -242,6 +244,7 @@ export type ContinueHostAdapterOptions = { launchHost?: ContinueHostLauncher modelProfile?: ResolvedModelProfile skillPackages?: RuntimeSkillPackage[] + launchEnvironmentProvider?: LaunchEnvironmentProvider } export type ContinueHostAdapterDependencies = { @@ -1511,7 +1514,7 @@ export class ContinueHostAdapter { GOODBUDDY_DISABLE_CONTINUE_UPDATES: '1' } const profile = this.options.modelProfile - const environment = profile + let environment = profile ? buildExplicitProfileRuntimeEnvironment( environmentOverrides, profile.authentication === 'api-key' && profile.apiKey @@ -1525,6 +1528,10 @@ export class ContinueHostAdapter { : undefined ) : buildRuntimeEnvironment(environmentOverrides) + environment = applyLaunchEnvironmentPath( + environment, + this.options.launchEnvironmentProvider + ) signal.throwIfAborted() let child: ContinueHostChild try { diff --git a/src/main/agent/create-runtime.test.ts b/src/main/agent/create-runtime.test.ts index 8f28acdf..cfd3aada 100644 --- a/src/main/agent/create-runtime.test.ts +++ b/src/main/agent/create-runtime.test.ts @@ -425,6 +425,9 @@ describe('createAgentRuntime model compatibility', () => { it('rejects remote process runtimes before invoking any local launcher', () => { const launch = vi.fn() + const launchEnvironmentProvider = vi.fn(() => + Object.freeze({ PATH: '/local-tools:/usr/bin' }) + ) const workspaceAccess = { dispose: vi.fn(async () => undefined) } as unknown as WorkspaceAccess @@ -437,6 +440,7 @@ describe('createAgentRuntime model compatibility', () => { }), { deepseekHarnessLauncher: launch, + launchEnvironmentProvider, executionSpace: { kind: 'ssh', hostId: 'host-one', @@ -449,5 +453,6 @@ describe('createAgentRuntime model compatibility', () => { ) ).toThrow('远程执行空间尚不可用') expect(launch).not.toHaveBeenCalled() + expect(launchEnvironmentProvider).not.toHaveBeenCalled() }) }) diff --git a/src/main/agent/create-runtime.ts b/src/main/agent/create-runtime.ts index 4b88371d..1ab59805 100644 --- a/src/main/agent/create-runtime.ts +++ b/src/main/agent/create-runtime.ts @@ -24,7 +24,10 @@ import type { RuntimeSkillPackage } from '../capabilities/capability-service' import type { BundledRuntimePaths } from './bundled-runtimes' -import type { ContinueHostLauncher } from './continue-host-adapter' +import { + ContinueHostAdapter, + type ContinueHostLauncher +} from './continue-host-adapter' import type { BrowserToolService } from '../browser/browser-model-tools' import type { ModelToolProviderLike } from './model-tool-provider' import type { SubagentScheduler } from '../assistant/subagent-scheduler' @@ -39,6 +42,7 @@ import { REMOTE_EXECUTION_SPACE_UNAVAILABLE, type ExecutionSpaceDescriptor } from '../execution-space' +import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider' const noSubagentTools: ModelToolProviderLike = { listTools: async () => [], @@ -67,6 +71,7 @@ export type AgentCapabilityContext = { executionSpace?: ExecutionSpaceDescriptor workspaceAccess?: WorkspaceAccess directModelSubagentScheduler?: SubagentScheduler + launchEnvironmentProvider?: LaunchEnvironmentProvider } function resolveContextCompression( @@ -217,7 +222,9 @@ export function createAgentRuntime( capabilities.mcpServers, undefined, capabilities.knowledgeGateway, - capabilities.webSearchEnabled === true + capabilities.webSearchEnabled === true, + {}, + capabilities.launchEnvironmentProvider ) }) } @@ -253,6 +260,14 @@ export function createAgentRuntime( process.env.GOODBUDDY_CONTINUE_HOST_CACHE?.trim() ?? '', launchHost: capabilities.continueHostLauncher, + createHostAdapter: capabilities.launchEnvironmentProvider + ? (options) => + new ContinueHostAdapter({ + ...options, + launchEnvironmentProvider: + capabilities.launchEnvironmentProvider + }) + : undefined, knowledgeGateway: capabilities.knowledgeGateway, mcpServers: capabilities.mcpServers, customization: settings?.runtimeCustomization.continue @@ -288,7 +303,9 @@ export function createAgentRuntime( defaultWorkspace: workspace, knowledgeGateway: capabilities.knowledgeGateway, mcpServers: capabilities.mcpServers, - customization: settings?.runtimeCustomization.opencode + customization: settings?.runtimeCustomization.opencode, + launchEnvironmentProvider: + capabilities.launchEnvironmentProvider }) } @@ -348,7 +365,9 @@ export function createAgentRuntime( : undefined, workspaceAccess: getWorkspaceAccess(), directModelSubagentScheduler: - capabilities.directModelSubagentScheduler + capabilities.directModelSubagentScheduler, + launchEnvironmentProvider: + capabilities.launchEnvironmentProvider }) } diff --git a/src/main/agent/deepseek-harness-utility-launcher.test.ts b/src/main/agent/deepseek-harness-utility-launcher.test.ts index 659d8344..493d2c6c 100644 --- a/src/main/agent/deepseek-harness-utility-launcher.test.ts +++ b/src/main/agent/deepseek-harness-utility-launcher.test.ts @@ -9,7 +9,8 @@ import { DEEPSEEK_HARNESS_CONTROL_VERSION, DEEPSEEK_HARNESS_CREDENTIAL_REF, createDeepSeekHarnessUtilityLauncher, - parseHarnessControlMessage + parseHarnessControlMessage, + type DeepSeekHarnessFork } from './deepseek-harness-utility-launcher' class FakeUtility extends EventEmitter { @@ -129,6 +130,63 @@ describe('DeepSeek Harness utility launcher', () => { ) }) + it('refreshes the filtered tool PATH for every Host root', async () => { + const { dshHome, hostPath, launchOptions } = await fixture() + const utilities = [new FakeUtility(), new FakeUtility()] + const fork = vi.fn( + () => utilities.shift() as never + ) + let generation = 0 + const launcher = createDeepSeekHarnessUtilityLauncher({ + bundledHostPath: hostPath, + dshHome, + environment: { + PATH: 'C:\\System' + }, + launchEnvironmentProvider: () => + Object.freeze({ + PATH: `C:\\GoodBuddy\\tools-${++generation};C:\\System`, + OPENAI_API_KEY: 'provider-secret', + ELECTRON_RUN_AS_NODE: '1' + }), + fork + }) + + for (let index = 0; index < 2; index += 1) { + const launching = launcher(launchOptions) + await vi.waitFor(() => + expect(fork).toHaveBeenCalledTimes(index + 1) + ) + const launchedUtility = ( + fork.mock.results[index]?.value + ) as unknown as FakeUtility + await vi.waitFor(() => + expect(launchedUtility.messages).toHaveLength(1) + ) + launchedUtility.emit('message', { + protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL, + version: DEEPSEEK_HARNESS_CONTROL_VERSION, + type: 'ready', + failedExtensionIds: [] + }) + await launching + } + + const environments = fork.mock.calls.map( + (call) => call[2]?.env as NodeJS.ProcessEnv + ) + expect(environments[0]?.PATH).toBe( + 'C:\\GoodBuddy\\tools-1;C:\\System' + ) + expect(environments[1]?.PATH).toBe( + 'C:\\GoodBuddy\\tools-2;C:\\System' + ) + for (const environment of environments) { + expect(environment).not.toHaveProperty('OPENAI_API_KEY') + expect(environment).not.toHaveProperty('ELECTRON_RUN_AS_NODE') + } + }) + it('persists extension startup failures before exposing the child', async () => { const { root, dshHome, hostPath, launchOptions } = await fixture() diff --git a/src/main/agent/deepseek-harness-utility-launcher.ts b/src/main/agent/deepseek-harness-utility-launcher.ts index 3c79ab85..41c3ec15 100644 --- a/src/main/agent/deepseek-harness-utility-launcher.ts +++ b/src/main/agent/deepseek-harness-utility-launcher.ts @@ -19,6 +19,8 @@ import { parseHarnessControlMessage, type DeepSeekHarnessControlMessage as HarnessControlMessage } from './deepseek-harness-control-protocol' +import { applyLaunchEnvironmentPath } from './process-environment' +import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider' export { controlledHarnessHostConfigSchema, @@ -49,6 +51,7 @@ export type DeepSeekHarnessUtilityLauncherOptions = { bundledHostPath: string dshHome: string environment: NodeJS.ProcessEnv + launchEnvironmentProvider?: LaunchEnvironmentProvider fork: DeepSeekHarnessFork terminateProcess?: (utility: UtilityProcess) => void onExtensionStartupFailures?: ( @@ -162,7 +165,10 @@ export function createDeepSeekHarnessUtilityLauncher( options.signal.throwIfAborted() const utility = launcherOptions.fork(canonicalHostPath, [], { cwd: canonicalWorkspace, - env: launcherOptions.environment, + env: applyLaunchEnvironmentPath( + { ...launcherOptions.environment }, + launcherOptions.launchEnvironmentProvider + ), serviceName: 'GoodBuddy DeepSeek Harness Host', stdio: ['ignore', 'ignore', 'pipe'] }) diff --git a/src/main/agent/knowledge-mcp-gateway.ts b/src/main/agent/knowledge-mcp-gateway.ts index 751db4c5..da6ebb0f 100644 --- a/src/main/agent/knowledge-mcp-gateway.ts +++ b/src/main/agent/knowledge-mcp-gateway.ts @@ -63,6 +63,7 @@ import { listAllMcpTools, normalizeMcpToolSchema } from './mcp-tool-utils' +import type { LaunchEnvironmentProvider } from '../local-tool-environment' const MAX_REQUEST_BODY_BYTES = 64 * 1024 const MAX_RESULT_BYTES = 128 * 1024 @@ -215,6 +216,7 @@ export type KnowledgeMcpGatewayOptions = { now?: () => number magicNotesDatabase?: MagicNotesDatabase configService?: GoodBuddyConfigService + launchEnvironmentProvider?: LaunchEnvironmentProvider } function toMagicNoteToolSummary( @@ -339,6 +341,7 @@ export class KnowledgeMcpGateway { private readonly maximumBodyBytes: number private readonly magicNotesDatabase?: MagicNotesDatabase private readonly configService?: GoodBuddyConfigService + private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider private server?: Server private endpoint?: string @@ -360,6 +363,7 @@ export class KnowledgeMcpGateway { this.now = options.now ?? Date.now this.magicNotesDatabase = options.magicNotesDatabase this.configService = options.configService + this.launchEnvironmentProvider = options.launchEnvironmentProvider } async start(): Promise { @@ -888,10 +892,13 @@ export class KnowledgeMcpGateway { capability.brokerController.signal ]) try { - await client.connect(createMcpTransport(server), { + await client.connect( + createMcpTransport(server, this.launchEnvironmentProvider), + { timeout: CUSTOM_MCP_TIMEOUT_MS, signal - }) + } + ) const listedAtChangeVersion = dynamicToolsChangeVersion const tools = await this.listAllCustomMcpTools( client, diff --git a/src/main/agent/model-runtime.ts b/src/main/agent/model-runtime.ts index 3a81da3b..34a4d8f5 100644 --- a/src/main/agent/model-runtime.ts +++ b/src/main/agent/model-runtime.ts @@ -11,6 +11,7 @@ import type { import { defaultAnthropicMaximumOutputTokens } from '../../shared/contracts' import type { ResolvedMcpServer } from '../capabilities/capability-service' import type { BrowserToolService } from '../browser/browser-model-tools' +import type { LaunchEnvironmentProvider } from '../local-tool-environment' import { LocalWorkspaceAccess, type WorkspaceAccess @@ -250,6 +251,7 @@ export type ModelRuntimeOptions = { } maximumOutputTokens?: number directModelSubagentScheduler?: SubagentScheduler + launchEnvironmentProvider?: LaunchEnvironmentProvider } function getErrorMessage(value: unknown): string | undefined { @@ -1585,7 +1587,8 @@ export class ModelAgentRuntime implements AgentRuntime { { processService: new LocalDirectModelProcessService(), subagentService: directModelSubagentService - } + }, + options.launchEnvironmentProvider ) } } diff --git a/src/main/agent/model-tool-provider.test.ts b/src/main/agent/model-tool-provider.test.ts index ef0a0dba..9c962e9f 100644 --- a/src/main/agent/model-tool-provider.test.ts +++ b/src/main/agent/model-tool-provider.test.ts @@ -152,6 +152,31 @@ describe('ModelToolProvider', () => { ) }) + it('passes the launch environment provider to new stdio MCP connections', async () => { + const workspace = await createWorkspace() + const launchEnvironmentProvider = vi.fn(() => + Object.freeze({ PATH: '/managed-tools:/usr/bin' }) + ) + const provider = new ModelToolProvider( + workspace, + [createMcpServer()], + undefined, + undefined, + false, + {}, + launchEnvironmentProvider + ) + + await provider.listTools(toolContext, new AbortController().signal) + + expect(mocks.createMcpTransport).toHaveBeenCalledWith( + expect.objectContaining({ transport: 'stdio' }), + launchEnvironmentProvider + ) + expect(launchEnvironmentProvider).not.toHaveBeenCalled() + await provider.dispose() + }) + it('provides bounded workspace read, list, and atomic write tools', async () => { const workspace = await createWorkspace() await mkdir(join(workspace, 'docs')) diff --git a/src/main/agent/model-tool-provider.ts b/src/main/agent/model-tool-provider.ts index 341abe8d..f7f01c8a 100644 --- a/src/main/agent/model-tool-provider.ts +++ b/src/main/agent/model-tool-provider.ts @@ -43,6 +43,7 @@ import { type DirectModelSubagentUsageEvent, directModelSubagentInputSchema } from '../assistant/direct-model-subagent-service' +import type { LaunchEnvironmentProvider } from '../local-tool-environment' const MAX_MODEL_TOOLS = 100 const MAX_MCP_SERVERS = 16 @@ -618,7 +619,8 @@ export class ModelToolProvider implements ModelToolProviderLike { private readonly knowledgeGateway?: KnowledgeMcpGateway, private readonly webSearchEnabled = false, private readonly programming: - ModelToolProviderProgrammingOptions = {} + ModelToolProviderProgrammingOptions = {}, + private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider ) { this.workspaceAccess = typeof workspace === 'string' @@ -909,10 +911,13 @@ export class ModelToolProvider implements ModelToolProviderLike { this.clients.add(client) clientScope.add(client) try { - await client.connect(createMcpTransport(server), { + await client.connect( + createMcpTransport(server, this.launchEnvironmentProvider), + { timeout: MCP_TIMEOUT_MS, signal - }) + } + ) const listedAtChangeVersion = dynamicToolsChangeVersion const tools = await listAllMcpTools( client, diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index 15500d1a..d21c6508 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -312,7 +312,13 @@ describe('OpenCodeRuntime embedded launcher', () => { const runtime = new OpenCodeRuntime( options({ binaryPath: 'C:\\Configured\\opencode.exe', - configPath + configPath, + launchEnvironmentProvider: () => + Object.freeze({ + PATH: 'C:\\GoodBuddy\\tools;C:\\Windows', + OPENAI_API_KEY: 'must-not-leak', + ELECTRON_RUN_AS_NODE: '1' + }) }), { spawn: spawnMock as unknown as typeof spawn, @@ -399,6 +405,12 @@ describe('OpenCodeRuntime embedded launcher', () => { 'goodbuddy' ) expect(spawnOptions?.env?.OPENCODE_SERVER_PASSWORD).toBeTruthy() + expect(spawnOptions?.env?.PATH).toBe( + 'C:\\GoodBuddy\\tools;C:\\Windows' + ) + expect(spawnOptions?.env).not.toHaveProperty( + 'ELECTRON_RUN_AS_NODE' + ) expect( Buffer.from( clientOptions?.headers?.Authorization?.slice(6) ?? '', diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index 2449925b..1991e0c8 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -47,10 +47,12 @@ import { detectRuntimeBinary } from './runtime-discovery' import { getAvailableLoopbackPort } from './loopback-port' import type { ResolvedModelProfile } from '../runtime-settings-store' import { + applyLaunchEnvironmentPath, buildExplicitProfileRuntimeEnvironment, buildRuntimeEnvironment, runtimePrivacyEnvironment } from './process-environment' +import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider' import { boundedToolDetail, safeToolErrorDetail @@ -489,6 +491,7 @@ export type OpenCodeRuntimeOptions = { knowledgeGateway?: KnowledgeMcpGateway mcpServers?: ResolvedMcpServer[] customization?: RuntimeCustomizationSettings['opencode'] + launchEnvironmentProvider?: LaunchEnvironmentProvider } function boundedNativeIdentifier(value: unknown): string | undefined { @@ -904,7 +907,7 @@ export class OpenCodeRuntime implements AgentRuntime { throw new Error('OpenCode Server 启动已取消') } const profile = this.options.modelProfile - const env = profile + let env = profile ? buildExplicitProfileRuntimeEnvironment( runtimePrivacyEnvironment, profile.authentication === 'api-key' && profile.apiKey @@ -918,6 +921,10 @@ export class OpenCodeRuntime implements AgentRuntime { : undefined ) : buildRuntimeEnvironment(runtimePrivacyEnvironment) + env = applyLaunchEnvironmentPath( + env, + this.options.launchEnvironmentProvider + ) delete env.OPENCODE_CONFIG delete env.OPENCODE_CONFIG_CONTENT delete env.OPENCODE_CONFIG_DIR diff --git a/src/main/agent/process-environment.test.ts b/src/main/agent/process-environment.test.ts index c65074ec..908f431c 100644 --- a/src/main/agent/process-environment.test.ts +++ b/src/main/agent/process-environment.test.ts @@ -1,11 +1,57 @@ import { describe, expect, it } from 'vitest' import { + applyLaunchEnvironmentPath, + buildCredentialFilteredUserEnvironment, buildExplicitProfileRuntimeEnvironment, buildControlledHarnessEnvironment, buildRuntimeEnvironment } from './process-environment' describe('buildRuntimeEnvironment', () => { + it('uses a fresh tool PATH snapshot without importing credentials', () => { + let generation = 0 + const provider = () => + Object.freeze({ + PATH: `/managed-${++generation}:/system`, + OPENAI_API_KEY: 'must-not-leak', + ELECTRON_RUN_AS_NODE: '1' + }) + const source = { + PATH: '/system', + OPENAI_API_KEY: 'inherited-secret', + ELECTRON_RUN_AS_NODE: '1', + HOME: '/home/user' + } + + expect( + applyLaunchEnvironmentPath( + buildCredentialFilteredUserEnvironment(source), + provider + ) + ).toEqual({ + PATH: '/managed-1:/system', + HOME: '/home/user' + }) + expect( + applyLaunchEnvironmentPath( + buildCredentialFilteredUserEnvironment(source), + provider + ).PATH + ).toBe('/managed-2:/system') + }) + + it('preserves one inherited PATH casing without duplicate keys', () => { + expect( + applyLaunchEnvironmentPath( + { Path: 'C:\\Windows', PATH: 'duplicate', TEMP: 'C:\\Temp' }, + () => Object.freeze({ PATH: 'C:\\GoodBuddy;C:\\Windows' }) + ) + ).toEqual({ + Path: 'C:\\GoodBuddy;C:\\Windows', + TEMP: 'C:\\Temp' + }) + }) + it('keeps required runtime values and excludes unrelated parent secrets', () => { const environment = buildRuntimeEnvironment( { diff --git a/src/main/agent/process-environment.ts b/src/main/agent/process-environment.ts index e835cf2a..be41b550 100644 --- a/src/main/agent/process-environment.ts +++ b/src/main/agent/process-environment.ts @@ -1,3 +1,5 @@ +import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider' + export const runtimeProviderEnvironmentNames = [ 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', @@ -35,6 +37,7 @@ export function buildCredentialFilteredUserEnvironment( const upperName = name.toUpperCase() if ( value !== undefined && + upperName !== 'ELECTRON_RUN_AS_NODE' && !upperName.startsWith('GOODBUDDY_') && !upperName.startsWith('FACTORY_') && !userProcessCredentialEnvironmentNames.has(upperName) @@ -45,6 +48,39 @@ export function buildCredentialFilteredUserEnvironment( return environment } +/** + * Applies only the PATH prepared by the local tool environment. In + * particular, arbitrary values from that snapshot must not bypass the + * credential filtering performed by the caller. + */ +export function applyLaunchEnvironmentPath( + environment: NodeJS.ProcessEnv, + provider?: LaunchEnvironmentProvider +): NodeJS.ProcessEnv { + if (!provider) { + return environment + } + const launchEnvironment = provider() + const path = + launchEnvironment.PATH ?? + launchEnvironment.Path ?? + launchEnvironment.path + if (path === undefined) { + return environment + } + const result = { ...environment } + const existingPathName = Object.keys(environment).find( + (name) => name.toLowerCase() === 'path' + ) + for (const name of Object.keys(result)) { + if (name.toLowerCase() === 'path') { + delete result[name] + } + } + result[existingPathName ?? 'PATH'] = path + return result +} + const runtimeEnvironmentAllowlist = [ 'PATH', 'Path', diff --git a/src/main/application-settings-store.test.ts b/src/main/application-settings-store.test.ts index dc658975..e2cd1f96 100644 --- a/src/main/application-settings-store.test.ts +++ b/src/main/application-settings-store.test.ts @@ -1,10 +1,4 @@ -import { - mkdtemp, - readFile, - readdir, - rm, - writeFile -} from 'node:fs/promises' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -16,6 +10,13 @@ import { } from './application-settings-store' const temporaryDirectories: string[] = [] +const managedMigrationResolver = async () => ({}) + +function createApplicationSettingsStore( + filePath: string +): ApplicationSettingsStore { + return new ApplicationSettingsStore(filePath, managedMigrationResolver) +} async function createStore(): Promise<{ directory: string @@ -30,15 +31,15 @@ async function createStore(): Promise<{ return { directory, filePath, - store: new ApplicationSettingsStore(filePath) + store: createApplicationSettingsStore(filePath) } } afterEach(async () => { await Promise.all( - temporaryDirectories.splice(0).map((directory) => - rm(directory, { recursive: true, force: true }) - ) + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })) ) }) @@ -46,9 +47,7 @@ describe('ApplicationSettingsStore', () => { it('returns defaults without creating a settings file', async () => { const { directory, store } = await createStore() - await expect(store.get()).resolves.toEqual( - defaultApplicationSettings - ) + await expect(store.get()).resolves.toEqual(defaultApplicationSettings) await expect(readdir(directory)).resolves.toEqual([]) }) @@ -65,6 +64,7 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, @@ -75,6 +75,7 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, @@ -82,10 +83,11 @@ describe('ApplicationSettingsStore', () => { magicNoteCommentFormat: 'combined' }) expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ - version: 9, + version: 10, checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, @@ -108,13 +110,13 @@ describe('ApplicationSettingsStore', () => { remoteProjectsEnabled: true }) await expect( - new ApplicationSettingsStore(filePath).get() + createApplicationSettingsStore(filePath).get() ).resolves.toEqual({ ...defaultApplicationSettings, remoteProjectsEnabled: true }) expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ - version: 9, + version: 10, lastSeenReleaseNotesVersion: null, ...defaultApplicationSettings, remoteProjectsEnabled: true @@ -124,18 +126,19 @@ describe('ApplicationSettingsStore', () => { it('creates the parent directory and can reload persisted settings', async () => { const { directory } = await createStore() const filePath = join(directory, 'nested', 'application-settings.json') - const store = new ApplicationSettingsStore(filePath) + const store = createApplicationSettingsStore(filePath) await store.update({ checkUpdatesOnStartup: false, magicNotesEnabled: false }) await expect( - new ApplicationSettingsStore(filePath).get() + createApplicationSettingsStore(filePath).get() ).resolves.toEqual({ checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, @@ -161,6 +164,7 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, @@ -186,6 +190,7 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, @@ -211,6 +216,7 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, @@ -236,13 +242,14 @@ describe('ApplicationSettingsStore', () => { await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBeNull() await store.setLastSeenReleaseNotesVersion('0.8.18') await expect( - new ApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion() + createApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion() ).resolves.toBe('0.8.18') expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ - version: 9, + version: 10, checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, @@ -271,18 +278,17 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'after-save-auto', magicNoteCommentFormat: 'structured' }) - await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe( - '0.8.18' - ) + await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe('0.8.18') }) - it('lazily migrates version 6 to the default ModelScope source', async () => { + it('migrates version 6 to the default ModelScope source', async () => { const { filePath, store } = await createStore() const versionSix = { version: 6, @@ -299,21 +305,28 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'mirror', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'after-save-auto', magicNoteCommentFormat: 'structured' }) - expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual( - versionSix - ) + expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ + ...versionSix, + version: 10, + modelDownloadSource: 'modelscope', + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, + remoteProjectsEnabled: false, + magicNotesShowIncompleteTodoCount: true + }) await store.update({ modelDownloadSource: 'hugging-face' }) expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ ...versionSix, - version: 9, + version: 10, modelDownloadSource: 'hugging-face', + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, remoteProjectsEnabled: false, magicNotesShowIncompleteTodoCount: true }) @@ -337,18 +350,23 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined' }) - expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual( - versionSeven - ) + expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ + ...versionSeven, + version: 10, + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, + remoteProjectsEnabled: false, + magicNotesShowIncompleteTodoCount: true + }) }) - it('lazily migrates version 8 with Remote Projects disabled', async () => { + it('migrates version 8 with Remote Projects disabled', async () => { const { filePath, store } = await createStore() const versionEight = { version: 8, @@ -364,36 +382,102 @@ describe('ApplicationSettingsStore', () => { } await writeFile(filePath, JSON.stringify(versionEight), 'utf8') - await expect(store.get()).resolves.toEqual({ + const migratedSettings = await store.get() + expect(migratedSettings).toEqual({ checkUpdatesOnStartup: false, updateSource: 'mirror', modelDownloadSource: 'hugging-face', + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'narrative' }) - await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe( - '0.8.18' - ) - expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual( - versionEight - ) + await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe('0.8.18') + expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ + ...versionEight, + version: 10, + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, + remoteProjectsEnabled: false + }) await store.update({ checkUpdatesOnStartup: true }) expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ ...versionEight, - version: 9, + version: 10, checkUpdatesOnStartup: true, + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, remoteProjectsEnabled: false }) }) + it('persists independently resolved version 9 runtime selections exactly once', async () => { + const { filePath } = await createStore() + const versionNine = { + version: 9, + checkUpdatesOnStartup: false, + updateSource: 'mirror', + modelDownloadSource: 'hugging-face', + remoteProjectsEnabled: true, + magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: false, + magicNoteCommentMode: 'after-save-manual', + magicNoteCommentFormat: 'narrative', + lastSeenReleaseNotesVersion: '0.8.18' + } + await writeFile(filePath, JSON.stringify(versionNine), 'utf8') + let resolverCalls = 0 + const store = new ApplicationSettingsStore(filePath, async () => { + resolverCalls += 1 + return { + nodeExecutablePath: 'C:\\Tools\\node.exe', + pythonExecutablePath: 'relative/python' + } + }) + + await expect(store.get()).resolves.toEqual({ + checkUpdatesOnStartup: false, + updateSource: 'mirror', + modelDownloadSource: 'hugging-face', + remoteProjectsEnabled: true, + magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: false, + magicNoteCommentMode: 'after-save-manual', + magicNoteCommentFormat: 'narrative', + localToolEnvironment: { + node: { + source: 'custom', + executablePath: 'C:\\Tools\\node.exe' + }, + python: { source: 'managed' }, + artifactDownloadSource: 'native' + } + }) + expect(resolverCalls).toBe(1) + + const persisted = JSON.parse(await readFile(filePath, 'utf8')) + expect(persisted).toEqual({ + ...versionNine, + version: 10, + localToolEnvironment: { + node: { + source: 'custom', + executablePath: 'C:\\Tools\\node.exe' + }, + python: { source: 'managed' }, + artifactDownloadSource: 'native' + } + }) + const reloaded = new ApplicationSettingsStore(filePath, async () => { + throw new Error('A persisted migration must not resolve PATH again') + }) + await expect(reloaded.get()).resolves.toEqual(await store.get()) + }) + it('strictly rejects incomplete full settings', () => { expect( - applicationSettingsSchema.safeParse(defaultApplicationSettings) - .success + applicationSettingsSchema.safeParse(defaultApplicationSettings).success ).toBe(true) for (const input of [ {}, @@ -406,9 +490,7 @@ describe('ApplicationSettingsStore', () => { { checkUpdatesOnStartup: true }, null ]) { - expect(applicationSettingsSchema.safeParse(input).success).toBe( - false - ) + expect(applicationSettingsSchema.safeParse(input).success).toBe(false) } }) @@ -422,9 +504,9 @@ describe('ApplicationSettingsStore', () => { { anotherSetting: true }, null ]) { - expect( - applicationSettingsUpdateSchema.safeParse(input).success - ).toBe(false) + expect(applicationSettingsUpdateSchema.safeParse(input).success).toBe( + false + ) await expect(store.update(input)).rejects.toThrow() } await expect(readdir(directory)).resolves.toEqual([]) @@ -440,6 +522,7 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, @@ -456,7 +539,7 @@ describe('ApplicationSettingsStore', () => { updateSource: 'mirror' }) await expect( - new ApplicationSettingsStore(filePath).get() + createApplicationSettingsStore(filePath).get() ).resolves.toEqual({ ...defaultApplicationSettings, updateSource: 'mirror' @@ -494,9 +577,7 @@ describe('ApplicationSettingsStore', () => { expect(entries[0]).toMatch( /^application-settings\.json\.corrupt-\d+-[a-f0-9]{12}$/u ) - expect(await readFile(join(directory, entries[0] ?? ''), 'utf8')).toBe( - data - ) + expect(await readFile(join(directory, entries[0] ?? ''), 'utf8')).toBe(data) }) it('preserves settings created by a newer unsupported version', async () => { @@ -507,9 +588,7 @@ describe('ApplicationSettingsStore', () => { }) await writeFile(filePath, futureSettings, 'utf8') - await expect(store.get()).rejects.toThrow( - '不支持应用设置版本 99' - ) + await expect(store.get()).rejects.toThrow('不支持应用设置版本 99') expect(await readFile(filePath, 'utf8')).toBe(futureSettings) expect( (await readdir(directory)).some((name) => @@ -521,17 +600,15 @@ describe('ApplicationSettingsStore', () => { it('does not classify an I/O failure as corrupt settings', async () => { const { directory } = await createStore() const filePath = join(directory, 'settings-directory') - const store = new ApplicationSettingsStore(filePath) + const store = createApplicationSettingsStore(filePath) await writeFile(join(directory, 'sentinel'), 'unchanged', 'utf8') - const directoryStore = new ApplicationSettingsStore(directory) + const directoryStore = createApplicationSettingsStore(directory) await expect(directoryStore.get()).rejects.toThrow( 'Application settings could not be read' ) expect(await readdir(directory)).toEqual(['sentinel']) - await expect(store.get()).resolves.toEqual( - defaultApplicationSettings - ) + await expect(store.get()).resolves.toEqual(defaultApplicationSettings) }) it('serializes concurrent updates and leaves complete JSON', async () => { @@ -556,6 +633,7 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, @@ -563,10 +641,11 @@ describe('ApplicationSettingsStore', () => { magicNoteCommentFormat: 'combined' }) expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ - version: 9, + version: 10, checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, @@ -594,6 +673,7 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultApplicationSettings.localToolEnvironment, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, diff --git a/src/main/application-settings-store.ts b/src/main/application-settings-store.ts index bac86855..b32521df 100644 --- a/src/main/application-settings-store.ts +++ b/src/main/application-settings-store.ts @@ -1,10 +1,17 @@ -import { readFile } from 'node:fs/promises' +import { readFile, realpath, stat } from 'node:fs/promises' +import { delimiter, extname, isAbsolute, join } from 'node:path' import { z } from 'zod' import { applicationSettingsSchema, applicationSettingsUpdateSchema, type ApplicationSettings } from '../shared/application-settings-contracts' +import { + defaultLocalToolEnvironmentSettings, + localToolExecutablePathSchema, + type LocalToolEnvironmentSettings +} from '../shared/local-tool-environment-contracts' +import { inspectLocalToolExecutable } from './local-tool-environment/local-tool-environment' import { releaseVersionSchema } from '../shared/release-notes-contracts' import type { SettingsWarning } from '../shared/settings-warning-contracts' import { @@ -20,7 +27,7 @@ export { } from '../shared/application-settings-contracts' export type { ApplicationSettings } from '../shared/application-settings-contracts' -const CURRENT_SETTINGS_VERSION = 9 +const CURRENT_SETTINGS_VERSION = 10 const legacyStoredApplicationSettingsSchema = z .object({ @@ -50,6 +57,7 @@ const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema .omit({ updateSource: true, modelDownloadSource: true, + localToolEnvironment: true, remoteProjectsEnabled: true, magicNotesShowIncompleteTodoCount: true }) @@ -62,6 +70,7 @@ const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema .omit({ updateSource: true, modelDownloadSource: true, + localToolEnvironment: true, remoteProjectsEnabled: true, magicNotesShowIncompleteTodoCount: true }) @@ -74,6 +83,7 @@ const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema .omit({ modelDownloadSource: true, + localToolEnvironment: true, remoteProjectsEnabled: true, magicNotesShowIncompleteTodoCount: true }) @@ -83,26 +93,36 @@ const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema }) .strict() -const versionSevenStoredApplicationSettingsSchema = - applicationSettingsSchema - .omit({ - remoteProjectsEnabled: true, - magicNotesShowIncompleteTodoCount: true - }) - .extend({ - version: z.literal(7), - lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() - }) - .strict() +const versionSevenStoredApplicationSettingsSchema = applicationSettingsSchema + .omit({ + localToolEnvironment: true, + remoteProjectsEnabled: true, + magicNotesShowIncompleteTodoCount: true + }) + .extend({ + version: z.literal(7), + lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() + }) + .strict() -const versionEightStoredApplicationSettingsSchema = - applicationSettingsSchema - .omit({ remoteProjectsEnabled: true }) - .extend({ - version: z.literal(8), - lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() - }) - .strict() +const versionEightStoredApplicationSettingsSchema = applicationSettingsSchema + .omit({ + localToolEnvironment: true, + remoteProjectsEnabled: true + }) + .extend({ + version: z.literal(8), + lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() + }) + .strict() + +const versionNineStoredApplicationSettingsSchema = applicationSettingsSchema + .omit({ localToolEnvironment: true }) + .extend({ + version: z.literal(9), + lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() + }) + .strict() const storedApplicationSettingsSchema = applicationSettingsSchema .extend({ @@ -111,14 +131,90 @@ const storedApplicationSettingsSchema = applicationSettingsSchema }) .strict() -type StoredApplicationSettings = z.infer< - typeof storedApplicationSettingsSchema -> +type StoredApplicationSettings = z.infer + +export type LegacyLocalToolEnvironmentPaths = { + nodeExecutablePath?: string + pythonExecutablePath?: string +} + +export type LegacyLocalToolEnvironmentResolver = + () => Promise + +async function resolveExecutable( + kind: 'node' | 'python', + binaryNames: readonly string[] +): Promise { + const extensions = + process.platform === 'win32' + ? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD') + .split(';') + .filter((extension) => /^\.[A-Za-z0-9]+$/u.test(extension)) + : [''] + const directories = ( + process.env.PATH ?? + process.env.Path ?? + process.env.path ?? + '' + ) + .split(delimiter) + .map((directory) => directory.trim()) + .filter(isAbsolute) + + for (const directory of directories) { + for (const binaryName of binaryNames) { + const names = + process.platform === 'win32' && !extname(binaryName) + ? extensions.map((extension) => `${binaryName}${extension}`) + : [binaryName] + for (const name of names) { + try { + const executablePath = await realpath(join(directory, name)) + const metadata = await stat(executablePath) + if ( + metadata.isFile() && + localToolExecutablePathSchema.safeParse(executablePath).success + ) { + const candidate = await inspectLocalToolExecutable( + kind, + executablePath, + { baseEnvironment: process.env } + ) + if (candidate) { + return candidate.executablePath + } + } + } catch { + // Continue through PATH candidates. + } + } + } + } + return undefined +} + +export const resolveLegacyLocalToolEnvironmentPaths: LegacyLocalToolEnvironmentResolver = + async () => { + const [nodeExecutablePath, pythonExecutablePath] = await Promise.all([ + resolveExecutable('node', ['node']), + resolveExecutable( + 'python', + process.platform === 'win32' + ? ['python', 'python3'] + : ['python3', 'python'] + ) + ]) + return { + ...(nodeExecutablePath ? { nodeExecutablePath } : {}), + ...(pythonExecutablePath ? { pythonExecutablePath } : {}) + } + } export const defaultApplicationSettings: ApplicationSettings = { checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, @@ -132,7 +228,37 @@ export class ApplicationSettingsStore { private warnings: SettingsWarning[] = [] private updateQueue: Promise = Promise.resolve() - constructor(private readonly filePath: string) {} + constructor( + private readonly filePath: string, + private readonly legacyLocalToolEnvironmentResolver: LegacyLocalToolEnvironmentResolver = resolveLegacyLocalToolEnvironmentPaths + ) {} + + private async migrateLegacy( + settings: Omit< + StoredApplicationSettings, + 'localToolEnvironment' | 'version' + > & { version: number } + ): Promise { + const resolved = await this.legacyLocalToolEnvironmentResolver() + const selection = ( + executablePath: string | undefined + ): LocalToolEnvironmentSettings['node'] => + executablePath && + localToolExecutablePathSchema.safeParse(executablePath).success + ? { source: 'custom', executablePath } + : { source: 'managed' } + const next: StoredApplicationSettings = { + ...settings, + version: CURRENT_SETTINGS_VERSION, + localToolEnvironment: { + node: selection(resolved.nodeExecutablePath), + python: selection(resolved.pythonExecutablePath), + artifactDownloadSource: 'native' + } + } + await this.persist(next) + return next + } private async isolateCorruptFile(): Promise { await isolateCorruptSettingsFile( @@ -177,56 +303,57 @@ export class ApplicationSettingsStore { ) const result = storedApplicationSettingsSchema.safeParse(parsed) if (!result.success) { + const versionNineResult = + versionNineStoredApplicationSettingsSchema.safeParse(parsed) + if (versionNineResult.success) { + return this.migrateLegacy(versionNineResult.data) + } const versionEightResult = versionEightStoredApplicationSettingsSchema.safeParse(parsed) if (versionEightResult.success) { - this.settings = { + return this.migrateLegacy({ ...versionEightResult.data, version: CURRENT_SETTINGS_VERSION, remoteProjectsEnabled: false - } - return this.settings + }) } const versionSevenResult = versionSevenStoredApplicationSettingsSchema.safeParse(parsed) if (versionSevenResult.success) { - this.settings = { + return this.migrateLegacy({ ...versionSevenResult.data, version: CURRENT_SETTINGS_VERSION, remoteProjectsEnabled: false, magicNotesShowIncompleteTodoCount: true - } - return this.settings + }) } const versionSixResult = versionSixStoredApplicationSettingsSchema.safeParse(parsed) if (versionSixResult.success) { - this.settings = { + return this.migrateLegacy({ ...versionSixResult.data, version: CURRENT_SETTINGS_VERSION, modelDownloadSource: 'modelscope', remoteProjectsEnabled: false, magicNotesShowIncompleteTodoCount: true - } - return this.settings + }) } const versionFiveResult = versionFiveStoredApplicationSettingsSchema.safeParse(parsed) if (versionFiveResult.success) { - this.settings = { + return this.migrateLegacy({ ...versionFiveResult.data, version: CURRENT_SETTINGS_VERSION, updateSource: 'github', modelDownloadSource: 'modelscope', remoteProjectsEnabled: false, magicNotesShowIncompleteTodoCount: true - } - return this.settings + }) } const versionFourResult = versionFourStoredApplicationSettingsSchema.safeParse(parsed) if (versionFourResult.success) { - this.settings = { + return this.migrateLegacy({ ...versionFourResult.data, version: CURRENT_SETTINGS_VERSION, updateSource: 'github', @@ -234,13 +361,12 @@ export class ApplicationSettingsStore { remoteProjectsEnabled: false, magicNotesShowIncompleteTodoCount: true, lastSeenReleaseNotesVersion: null - } - return this.settings + }) } const versionThreeResult = versionThreeStoredApplicationSettingsSchema.safeParse(parsed) if (versionThreeResult.success) { - this.settings = { + return this.migrateLegacy({ ...versionThreeResult.data, version: CURRENT_SETTINGS_VERSION, updateSource: 'github', @@ -249,13 +375,12 @@ export class ApplicationSettingsStore { magicNotesShowIncompleteTodoCount: true, magicNoteCommentFormat: 'combined', lastSeenReleaseNotesVersion: null - } - return this.settings + }) } const versionTwoResult = versionTwoStoredApplicationSettingsSchema.safeParse(parsed) if (versionTwoResult.success) { - this.settings = { + return this.migrateLegacy({ ...versionTwoResult.data, version: CURRENT_SETTINGS_VERSION, updateSource: 'github', @@ -265,16 +390,14 @@ export class ApplicationSettingsStore { magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined', lastSeenReleaseNotesVersion: null - } - return this.settings + }) } const legacyResult = legacyStoredApplicationSettingsSchema.safeParse(parsed) if (legacyResult.success) { - this.settings = { + return this.migrateLegacy({ version: CURRENT_SETTINGS_VERSION, - checkUpdatesOnStartup: - legacyResult.data.checkUpdatesOnStartup, + checkUpdatesOnStartup: legacyResult.data.checkUpdatesOnStartup, updateSource: 'github', modelDownloadSource: 'modelscope', remoteProjectsEnabled: false, @@ -283,8 +406,7 @@ export class ApplicationSettingsStore { magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined', lastSeenReleaseNotesVersion: null - } - return this.settings + }) } await this.isolateCorruptFile() this.warnings = [{ code: 'application-settings-recovered' }] @@ -320,15 +442,14 @@ export class ApplicationSettingsStore { checkUpdatesOnStartup: stored.checkUpdatesOnStartup, updateSource: stored.updateSource, modelDownloadSource: stored.modelDownloadSource, + localToolEnvironment: stored.localToolEnvironment, remoteProjectsEnabled: stored.remoteProjectsEnabled, magicNotesEnabled: stored.magicNotesEnabled, magicNotesShowIncompleteTodoCount: stored.magicNotesShowIncompleteTodoCount, magicNoteCommentMode: stored.magicNoteCommentMode, magicNoteCommentFormat: stored.magicNoteCommentFormat, - ...(this.warnings.length > 0 - ? { warnings: [...this.warnings] } - : {}) + ...(this.warnings.length > 0 ? { warnings: [...this.warnings] } : {}) } } @@ -356,6 +477,7 @@ export class ApplicationSettingsStore { checkUpdatesOnStartup: next.checkUpdatesOnStartup, updateSource: next.updateSource, modelDownloadSource: next.modelDownloadSource, + localToolEnvironment: next.localToolEnvironment, remoteProjectsEnabled: next.remoteProjectsEnabled, magicNotesEnabled: next.magicNotesEnabled, magicNotesShowIncompleteTodoCount: diff --git a/src/main/capabilities/curated-mcp-launch.test.ts b/src/main/capabilities/curated-mcp-launch.test.ts index 6ff646ea..b7058482 100644 --- a/src/main/capabilities/curated-mcp-launch.test.ts +++ b/src/main/capabilities/curated-mcp-launch.test.ts @@ -77,6 +77,85 @@ describe('curated MCP launches', () => { }) }) + it('injects a fresh filtered tool environment into generic stdio', () => { + const previousKey = process.env.OPENAI_API_KEY + const previousElectronRunAsNode = + process.env.ELECTRON_RUN_AS_NODE + process.env.OPENAI_API_KEY = 'parent-secret' + process.env.ELECTRON_RUN_AS_NODE = '1' + let generation = 0 + try { + const server = { + transport: 'stdio', + command: 'custom-mcp', + args: ['--serve'] + } as ResolvedMcpServer + const provider = () => + Object.freeze({ + PATH: `/managed-${++generation}:/system`, + OPENAI_API_KEY: 'provider-secret', + ELECTRON_RUN_AS_NODE: '1' + }) + + createMcpTransport(server, provider) + createMcpTransport(server, provider) + + const calls = transportMocks.stdio.mock.calls.slice(-2) as Array< + [{ env: NodeJS.ProcessEnv }] + > + expect(calls[0]?.[0]).toMatchObject({ + env: { PATH: '/managed-1:/system' } + }) + expect(calls[1]?.[0]).toMatchObject({ + env: { PATH: '/managed-2:/system' } + }) + expect(calls[0]?.[0].env).not.toHaveProperty('OPENAI_API_KEY') + expect(calls[0]?.[0].env).not.toHaveProperty( + 'ELECTRON_RUN_AS_NODE' + ) + } finally { + if (previousKey === undefined) { + delete process.env.OPENAI_API_KEY + } else { + process.env.OPENAI_API_KEY = previousKey + } + if (previousElectronRunAsNode === undefined) { + delete process.env.ELECTRON_RUN_AS_NODE + } else { + process.env.ELECTRON_RUN_AS_NODE = + previousElectronRunAsNode + } + } + }) + + it('keeps curated explicit env authoritative with a provider', async () => { + const descriptor = await createCuratedMcpLaunch( + { + executable, + args: ['--stdio'], + cwd, + ownedRoots: [root], + ownerUid: 1000, + allowedEnvironmentNames: ['LANG'], + environment: { LANG: 'zh_CN.UTF-8' } + }, + { fileSystem: fileSystem() } + ) + + createMcpTransport(descriptor, () => + Object.freeze({ PATH: '/managed:/system' }) + ) + + expect(transportMocks.stdio).toHaveBeenLastCalledWith({ + command: executable, + args: ['--stdio'], + cwd, + env: { LANG: 'zh_CN.UTF-8', PATH: '/managed:/system' }, + stderr: 'ignore', + maxBufferSize: 2 * 1024 * 1024 + }) + }) + it('passes only validated values from an opaque curated descriptor', async () => { const validateLinuxDesktopEnvironment = vi.fn(async () => ({ DISPLAY: ':1', diff --git a/src/main/capabilities/mcp-client-transport.ts b/src/main/capabilities/mcp-client-transport.ts index b7624079..d3dd6a30 100644 --- a/src/main/capabilities/mcp-client-transport.ts +++ b/src/main/capabilities/mcp-client-transport.ts @@ -7,16 +7,33 @@ import { isCuratedMcpLaunchDescriptor, type CuratedMcpLaunchDescriptor } from './curated-mcp-launch' +import { + applyLaunchEnvironmentPath, + buildCredentialFilteredUserEnvironment +} from '../agent/process-environment' +import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider' export function createMcpTransport( - server: ResolvedMcpServer | CuratedMcpLaunchDescriptor + server: ResolvedMcpServer | CuratedMcpLaunchDescriptor, + launchEnvironmentProvider?: LaunchEnvironmentProvider ): Transport { if (isCuratedMcpLaunchDescriptor(server)) { + const environment = Object.fromEntries( + Object.entries( + applyLaunchEnvironmentPath( + { ...server.env }, + launchEnvironmentProvider + ) + ).filter( + (entry): entry is [string, string] => + entry[1] !== undefined + ) + ) return new StdioClientTransport({ command: server.command, args: [...server.args], cwd: server.cwd, - env: { ...server.env }, + env: environment, stderr: 'ignore', maxBufferSize: 2 * 1024 * 1024 }) @@ -27,9 +44,23 @@ export function createMcpTransport( } if (server.transport === 'stdio') { + const env = launchEnvironmentProvider + ? Object.fromEntries( + Object.entries( + applyLaunchEnvironmentPath( + buildCredentialFilteredUserEnvironment(), + launchEnvironmentProvider + ) + ).filter( + (entry): entry is [string, string] => + entry[1] !== undefined + ) + ) + : undefined return new StdioClientTransport({ command: server.command, args: server.args, + ...(env ? { env } : {}), stderr: 'ignore', maxBufferSize: 2 * 1024 * 1024 }) diff --git a/src/main/capabilities/mcp-tester.test.ts b/src/main/capabilities/mcp-tester.test.ts index 4783424c..b82df5ad 100644 --- a/src/main/capabilities/mcp-tester.test.ts +++ b/src/main/capabilities/mcp-tester.test.ts @@ -85,6 +85,32 @@ describe('testMcpServer', () => { mocks.client.close.mockResolvedValue(undefined) }) + it('uses the current launch environment for a new stdio test', async () => { + const launchEnvironmentProvider = vi.fn(() => + Object.freeze({ PATH: '/managed-tools:/usr/bin' }) + ) + + await testMcpServer( + { + ...common, + transport: 'stdio', + command: 'node', + args: ['server.js'] + }, + undefined, + launchEnvironmentProvider + ) + + expect(launchEnvironmentProvider).toHaveBeenCalledTimes(1) + expect(mocks.StdioClientTransport).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + PATH: '/managed-tools:/usr/bin' + }) + }) + ) + }) + it('uses separated stdio command arguments and closes the client', async () => { const result = await testMcpServer({ ...common, diff --git a/src/main/capabilities/mcp-tester.ts b/src/main/capabilities/mcp-tester.ts index c782095b..7717f197 100644 --- a/src/main/capabilities/mcp-tester.ts +++ b/src/main/capabilities/mcp-tester.ts @@ -2,19 +2,24 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import type { McpServerTestResult } from '../../shared/capability-contracts' import type { ResolvedMcpServer } from './capability-service' import { createMcpTransport } from './mcp-client-transport' +import type { LaunchEnvironmentProvider } from '../local-tool-environment' const MCP_TEST_TOTAL_TIMEOUT_MS = 12_000 const MCP_TEST_INACTIVITY_TIMEOUT_MS = 8_000 export async function testMcpServer( server: ResolvedMcpServer, - signal?: AbortSignal + signal?: AbortSignal, + launchEnvironmentProvider?: LaunchEnvironmentProvider ): Promise { const client = new Client({ name: 'goodbuddy', version: '0.1.0' }) - const transport = createMcpTransport(server) + const transport = createMcpTransport( + server, + launchEnvironmentProvider + ) const controller = new AbortController() let timedOut = false const abortFromCaller = (): void => { diff --git a/src/main/index.ts b/src/main/index.ts index 50e8a588..f209b40c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -98,7 +98,10 @@ import { createDeepSeekHarnessUtilityLauncher, type DeepSeekHarnessFork } from './agent/deepseek-harness-utility-launcher' -import { buildControlledHarnessEnvironment } from './agent/process-environment' +import { + buildControlledHarnessEnvironment, + buildCredentialFilteredUserEnvironment +} from './agent/process-environment' import { createStartupFailureDiagnostic, formatStartupFailureMessage, @@ -158,6 +161,10 @@ import { type DesktopDiagnosticFailureObserver } from './desktop-diagnostics' import { TerminalSessionManager } from './terminal/terminal-session-manager' +import { + LocalToolEnvironmentService, + resolveNpmCliPaths +} from './local-tool-environment' const legacyDefaultShortcut = defaultGlobalShortcutSettings.accelerator @@ -222,6 +229,7 @@ let managedRemoteExecutionServices: | ManagedRemoteExecutionServices | undefined let directModelSubagentScheduler: SubagentScheduler | undefined +let localToolEnvironmentService: LocalToolEnvironmentService | undefined type ManagedEmbeddingProvider = EmbeddingProvider & { dispose?: () => void | Promise @@ -665,6 +673,66 @@ if (hasSingleInstanceLock) { const applicationSettingsStore = new ApplicationSettingsStore( join(app.getPath('userData'), 'application-settings.json') ) + const toolEnvironmentRoot = join( + app.getPath('userData'), + 'tool-environment' + ) + const npmCliPaths = resolveNpmCliPaths({ + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + packaged: app.isPackaged + }) + const startupLocalToolEnvironmentService = + new LocalToolEnvironmentService({ + settingsStore: applicationSettingsStore, + binDirectory: join(toolEnvironmentRoot, 'bin'), + managedPythonRoot: join( + toolEnvironmentRoot, + 'managed-python' + ), + pythonArtifactCatalogPath: app.isPackaged + ? join( + process.resourcesPath, + 'tool-environment', + 'managed-python-artifacts.json' + ) + : join( + app.getAppPath(), + 'resources', + 'tool-environment', + 'managed-python-artifacts.json' + ), + packagedNpmCliPath: npmCliPaths.npmCliPath, + packagedNpxCliPath: npmCliPaths.npxCliPath, + electronExecutablePath: process.execPath, + selectExecutable: async (kind) => { + const result = await dialog.showOpenDialog(mainWindow!, { + title: + kind === 'python' + ? '选择 Python 可执行文件' + : '选择 Node.js 可执行文件', + properties: ['openFile'], + filters: + process.platform === 'win32' + ? [ + { + name: + kind === 'python' + ? 'Python 可执行文件' + : 'Node.js 可执行文件', + extensions: ['exe', 'cmd', 'bat'] + }, + { name: '所有文件', extensions: ['*'] } + ] + : [{ name: '所有文件', extensions: ['*'] }] + }) + return result.canceled ? undefined : result.filePaths[0] + }, + baseEnvironment: buildCredentialFilteredUserEnvironment() + }) + await startupLocalToolEnvironmentService.initialize() + localToolEnvironmentService = + startupLocalToolEnvironmentService const sshHostStore = new SshHostStore( join(app.getPath('userData'), 'ssh-hosts.json'), secureCipher @@ -886,6 +954,8 @@ if (hasSingleInstanceLock) { environment: buildControlledHarnessEnvironment( deepSeekHarnessHome ), + launchEnvironmentProvider: + startupLocalToolEnvironmentService.launchEnvironmentProvider, fork: forkDeepSeekHarness, terminateProcess: terminateHarnessUtilityProcess, onExtensionStartupFailures: (extensionIds) => @@ -939,7 +1009,9 @@ if (hasSingleInstanceLock) { startupKnowledgeService, { magicNotesDatabase: startupAssistantDatabase, - configService: goodbuddyConfigService + configService: goodbuddyConfigService, + launchEnvironmentProvider: + startupLocalToolEnvironmentService.launchEnvironmentProvider } ) knowledgeGateway = startupKnowledgeGateway @@ -990,7 +1062,9 @@ if (hasSingleInstanceLock) { webSearchEnabled: webSearchCapability?.enabled, executionSpace, workspaceAccess: executionSpace?.workspaceAccess, - directModelSubagentScheduler + directModelSubagentScheduler, + launchEnvironmentProvider: + startupLocalToolEnvironmentService.launchEnvironmentProvider }) } const createConfiguredRuntime = async ( @@ -1389,7 +1463,8 @@ if (hasSingleInstanceLock) { remoteEnvironmentUpdateService, agentPackageManager, startupRemoteAgentServices.connectionManager, - terminalSessionManager + terminalSessionManager, + startupLocalToolEnvironmentService ) removeFeedbackIpcHandler = registerFeedbackIpcHandler( mainWindow, @@ -1508,6 +1583,7 @@ app.on('before-quit', (event) => { () => documentOcrBroker?.dispose() ], [() => terminalSessionManager?.dispose()], + [() => localToolEnvironmentService?.dispose()], [() => managedRemoteExecutionServices?.dispose()], [() => remoteAgentServices?.dispose()], [() => knowledgeGateway?.dispose()], diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index ad422a90..e274737b 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -56,6 +56,27 @@ describe('embedding IPC boundary', () => { }) }) +describe('local tool environment IPC boundary', () => { + it('trust-checks every operation and accepts only shared bounded schemas', async () => { + const source = await readFile( + join(process.cwd(), 'src', 'main', 'ipc.ts'), + 'utf8' + ) + const region = + source.match( + /const requireLocalToolEnvironmentService[\s\S]*?registerHandler\(ipcChannels\.shortcutSettingsGet/u + )?.[0] ?? '' + expect(region.match(/assertTrustedSender\(event, window\)/gu)).toHaveLength(8) + expect(region).toContain('localToolEnvironmentSettingsSchema.parse(input)') + expect(region).toContain('localToolKindInputSchema.parse(input)') + expect(region).toContain('localToolDiagnoseInputSchema.parse(input)') + expect(region.match(/z\.undefined\(\)\.parse\(input\)/gu)).toHaveLength(5) + expect(region).not.toMatch( + /\b(?:command|args|env|url|hash|installDirectory|shimContents)\b/u + ) + }) +}) + describe('terminal IPC boundary', () => { afterEach(() => { electronMocks.handlers.clear() diff --git a/src/main/ipc.ts b/src/main/ipc.ts index a1521039..a3728679 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -124,6 +124,12 @@ import { weComChannelSettingsInputSchema } from '../shared/channel-settings-contracts' import { applicationSettingsUpdateSchema } from '../shared/application-settings-contracts' +import { + localToolDiagnoseInputSchema, + localToolEnvironmentProgressSchema, + localToolEnvironmentSettingsSchema, + localToolKindInputSchema +} from '../shared/local-tool-environment-contracts' import { agentPackageArchitectureRequestSchema, agentPackageDownloadProgressSchema, @@ -324,6 +330,7 @@ import { SqliteChannelOutbox } from './channels/sqlite-channel-state' import type { ApplicationSettingsStore } from './application-settings-store' +import type { LocalToolEnvironmentService } from './local-tool-environment' import { getUpdateDownloadPage, type VersionChecker @@ -1117,7 +1124,8 @@ export function registerIpcHandlers( RemoteAgentConnectionManager, 'getHostConnectionState' | 'onHostConnectionStateChange' >, - terminalSessionManager?: TerminalSessionManager + terminalSessionManager?: TerminalSessionManager, + localToolEnvironmentService?: LocalToolEnvironmentService ): () => Promise { type ActiveRequestLease = { controller: AbortController @@ -1216,6 +1224,17 @@ export function registerIpcHandlers( : result }) } + const removeLocalToolEnvironmentProgressListener = + localToolEnvironmentService?.onProgress((progress) => { + if (!window.webContents.isDestroyed()) { + sendValidatedProgress( + window.webContents, + ipcChannels.localToolEnvironmentProgress, + localToolEnvironmentProgressSchema, + progress + ) + } + }) const resolveRequestRuntime = async ( request: Pick< AgentRequest, @@ -5367,6 +5386,80 @@ export function registerIpcHandlers( } ) + const requireLocalToolEnvironmentService = + (): LocalToolEnvironmentService => { + if (!localToolEnvironmentService) { + throw new Error('本地工具环境服务不可用') + } + return localToolEnvironmentService + } + + registerHandler( + ipcChannels.localToolEnvironmentGet, + (event, input: unknown) => { + assertTrustedSender(event, window) + z.undefined().parse(input) + return requireLocalToolEnvironmentService().getSnapshot() + } + ) + registerHandler( + ipcChannels.localToolEnvironmentUpdate, + (event, input: unknown) => { + assertTrustedSender(event, window) + return requireLocalToolEnvironmentService().updateSettings( + localToolEnvironmentSettingsSchema.parse(input) + ) + } + ) + registerHandler( + ipcChannels.localToolEnvironmentRefresh, + (event, input: unknown) => { + assertTrustedSender(event, window) + z.undefined().parse(input) + return requireLocalToolEnvironmentService().refreshCandidates() + } + ) + registerHandler( + ipcChannels.localToolEnvironmentSelectExecutable, + (event, input: unknown) => { + assertTrustedSender(event, window) + const { kind } = localToolKindInputSchema.parse(input) + return requireLocalToolEnvironmentService().selectExecutable(kind) + } + ) + registerHandler( + ipcChannels.localToolEnvironmentDiagnose, + (event, input: unknown) => { + assertTrustedSender(event, window) + const { kind } = localToolDiagnoseInputSchema.parse(input) + return requireLocalToolEnvironmentService().diagnose(kind) + } + ) + registerHandler( + ipcChannels.localToolEnvironmentInstallPython, + (event, input: unknown) => { + assertTrustedSender(event, window) + z.undefined().parse(input) + return requireLocalToolEnvironmentService().installPython() + } + ) + registerHandler( + ipcChannels.localToolEnvironmentCancelPython, + (event, input: unknown) => { + assertTrustedSender(event, window) + z.undefined().parse(input) + return requireLocalToolEnvironmentService().cancelPython() + } + ) + registerHandler( + ipcChannels.localToolEnvironmentRemovePython, + (event, input: unknown) => { + assertTrustedSender(event, window) + z.undefined().parse(input) + return requireLocalToolEnvironmentService().removePython() + } + ) + registerHandler(ipcChannels.shortcutSettingsGet, (event) => { assertTrustedSender(event, window) if (!shortcutSettingsService) { @@ -6944,7 +7037,9 @@ export function registerIpcHandlers( return testMcpServer( await capabilityService.getResolvedMcpServer( mcpServerIdSchema.parse(input) - ) + ), + undefined, + localToolEnvironmentService?.launchEnvironmentProvider ) } ) @@ -7966,6 +8061,8 @@ export function registerIpcHandlers( return async () => { shuttingDown = true + removeLocalToolEnvironmentProgressListener?.() + await localToolEnvironmentService?.dispose() removeBrowserStateListener?.() lastSentBrowserFrames.clear() removeRemoteAgentConnectionStatusListener?.() diff --git a/src/main/local-tool-environment/index.ts b/src/main/local-tool-environment/index.ts new file mode 100644 index 00000000..b4846d87 --- /dev/null +++ b/src/main/local-tool-environment/index.ts @@ -0,0 +1,21 @@ +export { + createLocalToolEnvironment, + resolveNpmCliPaths +} from './local-tool-environment' +export type { + LocalToolDiagnostic, + LocalToolEnvironment, + LocalToolEnvironmentDependencies, + LocalToolEnvironmentDiagnostics, + LocalToolEnvironmentOptions, + LocalToolName, + LocalToolProbeProcess, + LocalToolProbeSpawn, + LocalToolSource +} from './local-tool-environment' +export { LocalToolEnvironmentService } from './local-tool-environment-service' +export type { + LocalToolEnvironmentServiceDependencies, + LocalToolEnvironmentServiceOptions +} from './local-tool-environment-service' +export type { LaunchEnvironmentProvider } from './launch-environment-provider' diff --git a/src/main/local-tool-environment/launch-environment-provider.ts b/src/main/local-tool-environment/launch-environment-provider.ts new file mode 100644 index 00000000..fe2d1968 --- /dev/null +++ b/src/main/local-tool-environment/launch-environment-provider.ts @@ -0,0 +1,5 @@ +/** + * Supplies a fresh, immutable snapshot of the environment prepared for local + * tool launches. Consumers must only copy the values they explicitly support. + */ +export type LaunchEnvironmentProvider = () => Readonly diff --git a/src/main/local-tool-environment/local-tool-environment-service.test.ts b/src/main/local-tool-environment/local-tool-environment-service.test.ts new file mode 100644 index 00000000..5d2e7bba --- /dev/null +++ b/src/main/local-tool-environment/local-tool-environment-service.test.ts @@ -0,0 +1,311 @@ +import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { EventEmitter } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + defaultLocalToolEnvironmentSettings, + type LocalToolEnvironmentSettings +} from '../../shared/local-tool-environment-contracts' +import { + LocalToolEnvironmentService, + validateManagedPython +} from './local-tool-environment-service' +import type { LocalToolEnvironmentServiceOptions } from './local-tool-environment-service' +import type { + LocalToolProbeProcess, + LocalToolProbeSpawn +} from './local-tool-environment' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ) +}) + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-local-tools-')) + roots.push(root) + const node = join(root, 'node.exe') + const python = join(root, 'python.exe') + await Promise.all([writeFile(node, ''), writeFile(python, '')]) + let settings: LocalToolEnvironmentSettings = { + ...defaultLocalToolEnvironmentSettings + } + const settingsStore = { + get: vi.fn(async () => ({ localToolEnvironment: settings })), + update: vi.fn(async (input: { localToolEnvironment: LocalToolEnvironmentSettings }) => { + settings = input.localToolEnvironment + return { localToolEnvironment: settings } + }) + } + const options: LocalToolEnvironmentServiceOptions = { + settingsStore: settingsStore as never, + binDirectory: join(root, 'bin'), + managedPythonRoot: join(root, 'managed-python'), + pythonArtifactCatalogPath: join( + process.cwd(), + 'resources', + 'tool-environment', + 'managed-python-artifacts.json' + ), + packagedNpmCliPath: join(root, 'npm-cli.js'), + packagedNpxCliPath: join(root, 'npx-cli.js'), + selectExecutable: vi.fn(async () => python), + baseEnvironment: { PATH: root }, + platform: 'win32', + arch: 'x64' + } + const service = new LocalToolEnvironmentService(options, { + toolEnvironment: { + platform: 'win32', + spawnProcess: fakeInspectionSpawn() + } + }) + return { service, settingsStore, node, python, options } +} + +function fakeInspectionSpawn( + invalidPath?: string +): LocalToolProbeSpawn { + return (command, args) => { + const child = new EventEmitter() as LocalToolProbeProcess & EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + } + child.exitCode = null + child.killed = false + child.pid = 1234 + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.kill = vi.fn(() => true) + queueMicrotask(() => { + const invalid = command === invalidPath + const python = args[0] === '-I' + if (!invalid) { + child.stdout.emit( + 'data', + JSON.stringify({ + version: python ? '3.12.8' : '22.14.0', + architecture: 'x64' + }) + ) + } + child.exitCode = invalid ? 1 : 0 + child.emit('close', child.exitCode) + }) + return child + } +} + +describe('LocalToolEnvironmentService', () => { + it('does not discover PATH candidates until explicitly refreshed', async () => { + const { service, node, python } = await fixture() + expect((await service.getSnapshot()).candidates).toEqual([]) + + const snapshot = await service.refreshCandidates() + expect(snapshot.candidates).toEqual([ + { + kind: 'node', + executablePath: node, + version: '22.14.0', + architecture: 'x64' + }, + { + kind: 'python', + executablePath: python, + version: '3.12.8', + architecture: 'x64' + } + ]) + }) + + it('persists the complete nested settings object through one store authority', async () => { + const { service, settingsStore, node } = await fixture() + const next: LocalToolEnvironmentSettings = { + node: { source: 'custom', executablePath: node }, + python: { source: 'managed' }, + artifactDownloadSource: 'oss' + } + await service.updateSettings(next) + expect(settingsStore.update).toHaveBeenCalledWith({ + localToolEnvironment: next + }) + await expect( + service.updateSettings({ ...next, command: 'node' }) + ).rejects.toThrow() + }) + + it('publishes immutable snapshots and replaces them only after rebuilding', async () => { + const { service, node } = await fixture() + await service.initialize() + const initial = service.launchEnvironmentProvider() + const initialPath = initial.PATH + + expect(Object.isFrozen(initial)).toBe(true) + expect(initial.PATH).toContain('bin') + + await service.updateSettings({ + ...defaultLocalToolEnvironmentSettings, + node: { source: 'custom', executablePath: node } + }) + const updated = service.launchEnvironmentProvider() + + expect(updated).not.toBe(initial) + expect(service.launchEnvironmentProvider()).toBe(updated) + expect(updated.PATH).not.toBe(initial.PATH) + expect(initial.PATH).toBe(initialPath) + expect(Object.isFrozen(updated)).toBe(true) + }) + + it('keeps executable paths inside the Main-owned picker callback', async () => { + const { service, settingsStore, python } = await fixture() + const snapshot = await service.selectExecutable('python') + expect(settingsStore.update).toHaveBeenCalledWith({ + localToolEnvironment: { + ...defaultLocalToolEnvironmentSettings, + python: { source: 'custom', executablePath: python } + } + }) + expect(snapshot.settings.python).toEqual({ + source: 'custom', + executablePath: python + }) + }) + + it('rejects an invalid new custom selection without persisting it', async () => { + const { settingsStore, node, options } = await fixture() + const invalid = join(node, '..', 'invalid-node.exe') + await writeFile(invalid, '') + const customService = new LocalToolEnvironmentService( + { + ...options, + selectExecutable: async () => invalid + }, + { + toolEnvironment: { + platform: 'win32', + spawnProcess: fakeInspectionSpawn(invalid) + } + } + ) + + await expect(customService.selectExecutable('node')).rejects.toThrow( + 'Selected node executable is invalid' + ) + expect(settingsStore.update).not.toHaveBeenCalled() + expect((await customService.getSnapshot()).settings.node).toEqual({ + source: 'managed' + }) + }) + + it('restores persisted settings when launch environment publication fails', async () => { + const { settingsStore, node, options } = await fixture() + const blockedBinDirectory = join(node, '..', 'blocked-bin') + await writeFile(blockedBinDirectory, '') + const customService = new LocalToolEnvironmentService( + { + ...options, + binDirectory: blockedBinDirectory + }, + { + toolEnvironment: { + platform: 'win32', + spawnProcess: fakeInspectionSpawn() + } + } + ) + + await expect( + customService.updateSettings({ + ...defaultLocalToolEnvironmentSettings, + node: { source: 'custom', executablePath: node } + }) + ).rejects.toThrow() + expect(settingsStore.update).toHaveBeenLastCalledWith({ + localToolEnvironment: defaultLocalToolEnvironmentSettings + }) + expect((await customService.getSnapshot()).settings).toEqual( + defaultLocalToolEnvironmentSettings + ) + }) + + it('validates pip and a disposable venv with separated bounded process calls', async () => { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-python-validation-')) + roots.push(root) + const python = join(root, 'python.exe') + const staging = join(root, 'stage') + const validationVenv = join(staging, '.validation-venv') + await Promise.all([writeFile(python, ''), mkdir(staging)]) + const calls: Array<{ + command: string + args: string[] + options: Parameters[2] + }> = [] + const spawnProcess: LocalToolProbeSpawn = (command, args, options) => { + calls.push({ command, args, options }) + const child = new EventEmitter() as LocalToolProbeProcess & + EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + } + child.exitCode = null + child.killed = false + child.pid = 4321 + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.kill = vi.fn(() => true) + queueMicrotask(() => { + if (args[0] === '-m' && args[1] === 'pip') { + child.stdout.emit('data', 'pip 24.3 from managed Python\n') + } else if (args[0] === '-m' && args[1] === 'venv') { + mkdirSync(validationVenv, { recursive: true }) + } else { + child.stdout.emit( + 'data', + JSON.stringify({ + version: '3.12.8', + architecture: 'AMD64', + ssl: 'OpenSSL 3.0.15', + stdlib: join(root, 'Lib') + }) + ) + } + child.exitCode = 0 + child.emit('close', 0) + }) + return child + } + + await validateManagedPython( + python, + staging, + '3.12.8', + 'x64', + new AbortController().signal, + 'win32', + { platform: 'win32', spawnProcess } + ) + + expect(calls).toHaveLength(3) + expect(calls[0]).toMatchObject({ + command: python, + args: ['-m', 'pip', '--version'] + }) + expect(calls[1]).toMatchObject({ + command: python, + args: ['-m', 'venv', '--without-pip', validationVenv] + }) + expect(calls[2]).toMatchObject({ + command: join(validationVenv, 'Scripts', 'python.exe') + }) + expect(calls[2]?.args.slice(0, 2)).toEqual(['-I', '-c']) + expect(calls.every(({ options }) => options.shell === false)).toBe(true) + await expect(access(validationVenv)).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) +}) diff --git a/src/main/local-tool-environment/local-tool-environment-service.ts b/src/main/local-tool-environment/local-tool-environment-service.ts new file mode 100644 index 00000000..bf730ed3 --- /dev/null +++ b/src/main/local-tool-environment/local-tool-environment-service.ts @@ -0,0 +1,665 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, realpath, rm, stat } from 'node:fs/promises' +import { delimiter, join } from 'node:path' +import spawn from 'cross-spawn' +import type { ApplicationSettingsStore } from '../application-settings-store' +import { + localToolEnvironmentSettingsSchema, + type ArtifactDownloadSource, + type LocalToolDiagnoseTarget, + type LocalToolEnvironmentProgress, + type LocalToolEnvironmentSettings, + type LocalToolEnvironmentSnapshot, + type LocalToolKind, + type LocalToolPythonOperation +} from '../../shared/local-tool-environment-contracts' +import { + createLocalToolEnvironment, + inspectLocalToolExecutable, + type LocalToolEnvironment, + type LocalToolEnvironmentDependencies +} from './local-tool-environment' +import { + cleanupManagedPythonOperations, + installManagedPython, + removeManagedPython +} from './managed-python-install' +import { + loadPythonArtifactCatalog, + selectPythonArtifact, + type PythonArtifactArch, + type PythonArtifactPlatform +} from './python-artifact-catalog' +import { downloadPythonArtifact } from './python-artifact-download' +import { extractPythonArtifact } from './python-artifact-extract' +import type { LaunchEnvironmentProvider } from './launch-environment-provider' +import { + terminateProcessTreeAndWait, + type WaitableProcessTreeChild +} from '../agent/child-process-termination' + +const candidateLimit = 40 +const pathDirectoryLimit = 32 +const validationOutputLimit = 8 * 1024 +const validationTimeoutMs = 15_000 +const validationTerminationWaitMs = 500 + +export type LocalToolEnvironmentServiceOptions = { + settingsStore: ApplicationSettingsStore + binDirectory: string + managedPythonRoot: string + pythonArtifactCatalogPath: string + packagedNpmCliPath: string + packagedNpxCliPath: string + selectExecutable: ( + kind: LocalToolKind + ) => Promise + electronExecutablePath?: string + baseEnvironment?: NodeJS.ProcessEnv + platform?: PythonArtifactPlatform + arch?: PythonArtifactArch +} + +export type LocalToolEnvironmentServiceDependencies = { + toolEnvironment?: LocalToolEnvironmentDependencies + transport?: typeof fetch +} + +type ProgressListener = (progress: LocalToolEnvironmentProgress) => void + +function pythonExecutable( + directory: string, + platform: PythonArtifactPlatform +): string { + return join(directory, 'python', platform === 'win32' ? 'python.exe' : 'bin/python3') +} + +async function existingFile(path: string): Promise { + try { + const canonical = await realpath(path) + return (await stat(canonical)).isFile() ? canonical : undefined + } catch { + return undefined + } +} + +async function discoverCandidates( + environment: NodeJS.ProcessEnv, + platform: PythonArtifactPlatform, + savedSettings: LocalToolEnvironmentSettings, + dependencies: LocalToolEnvironmentDependencies +): Promise { + const value = environment.PATH ?? environment.Path ?? environment.path ?? '' + const directories = value.split(delimiter).filter(Boolean).slice(0, pathDirectoryLimit) + const names: Readonly> = + platform === 'win32' + ? { node: ['node.exe'], python: ['python.exe', 'python3.exe'] } + : { node: ['node'], python: ['python3', 'python'] } + const paths: Array<{ kind: LocalToolKind; executablePath: string }> = [] + for (const kind of ['node', 'python'] as const) { + const selection = savedSettings[kind] + if (selection.source === 'custom') { + paths.push({ kind, executablePath: selection.executablePath }) + } + } + const seen = new Set() + for (const directory of directories) { + for (const kind of ['node', 'python'] as const) { + for (const name of names[kind]) { + if (paths.length >= candidateLimit) { + break + } + const path = await existingFile(join(directory, name)) + const key = path + ? `${kind}:${platform === 'win32' ? path.toLocaleLowerCase() : path}` + : undefined + if (path && key && !seen.has(key)) { + seen.add(key) + paths.push({ kind, executablePath: path }) + } + } + } + } + const inspected = await Promise.all( + paths.slice(0, candidateLimit).map(({ kind, executablePath }) => + inspectLocalToolExecutable( + kind, + executablePath, + { baseEnvironment: environment }, + dependencies + ) + ) + ) + const candidates: LocalToolEnvironmentSnapshot['candidates'] = [] + const canonicalSeen = new Set() + for (const candidate of inspected) { + if (!candidate) continue + const key = `${candidate.kind}:${ + platform === 'win32' + ? candidate.executablePath.toLocaleLowerCase() + : candidate.executablePath + }` + if (!canonicalSeen.has(key)) { + canonicalSeen.add(key) + candidates.push(candidate) + } + } + return candidates +} + +type ValidationProcess = WaitableProcessTreeChild & { + stdout?: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null + stderr?: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null +} + +function runValidationCommand( + command: string, + args: string[], + signal: AbortSignal, + platform: PythonArtifactPlatform, + dependencies: LocalToolEnvironmentDependencies +): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason) + return + } + let child: ValidationProcess + try { + child = (dependencies.spawnProcess ?? spawn)(command, args, { + detached: platform !== 'win32', + env: process.env, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + }) + } catch (error) { + reject(error) + return + } + + let output = '' + let outputBytes = 0 + let settled = false + let terminating = false + const finish = (error?: unknown): void => { + if (settled || terminating) return + settled = true + clearTimeout(timeout) + signal.removeEventListener('abort', onAbort) + if (error !== undefined) reject(error) + else resolve(output) + } + const terminate = (error: unknown): void => { + if (settled || terminating) return + terminating = true + clearTimeout(timeout) + signal.removeEventListener('abort', onAbort) + void ( + dependencies.terminateProcessTree ?? terminateProcessTreeAndWait + )(child, { + platform, + processGroup: platform !== 'win32', + signal: 'SIGKILL', + waitMs: validationTerminationWaitMs + }).then( + () => { + settled = true + reject(error) + }, + () => { + settled = true + reject(error) + } + ) + } + const onAbort = (): void => + terminate( + signal.reason ?? + new DOMException('Managed Python operation cancelled', 'AbortError') + ) + const timeout = setTimeout( + () => terminate(new Error('Managed Python validation timed out')), + validationTimeoutMs + ) + signal.addEventListener('abort', onAbort, { once: true }) + const collect = (chunk: Buffer | string): void => { + const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + outputBytes += value.byteLength + if (outputBytes > validationOutputLimit) { + terminate(new Error('Managed Python validation output exceeded its limit')) + return + } + output += value.toString('utf8') + } + child.stdout?.on('data', collect) + child.stderr?.on('data', collect) + child.once('error', (error: unknown) => finish(error)) + child.once('close', (code: unknown) => { + if (!terminating) { + finish( + code === 0 + ? undefined + : new Error(`Managed Python validation exited with code ${String(code)}`) + ) + } + }) + }) +} + +export async function validateManagedPython( + executablePath: string, + stagingDirectory: string, + version: string, + arch: PythonArtifactArch, + signal: AbortSignal, + platform: PythonArtifactPlatform, + dependencies: LocalToolEnvironmentDependencies = {} +): Promise { + const validationVenv = join(stagingDirectory, '.validation-venv') + try { + const pipOutput = await runValidationCommand( + executablePath, + ['-m', 'pip', '--version'], + signal, + platform, + dependencies + ) + if (!/\bpip\s+\d/u.test(pipOutput)) { + throw new Error('Managed Python pip is invalid') + } + await runValidationCommand( + executablePath, + ['-m', 'venv', '--without-pip', validationVenv], + signal, + platform, + dependencies + ) + const venvPython = + platform === 'win32' + ? join(validationVenv, 'Scripts', 'python.exe') + : join(validationVenv, 'bin', 'python3') + const script = [ + 'import json, platform, ssl, sysconfig', + 'print(json.dumps({"version": platform.python_version(), "architecture": platform.machine(), "ssl": ssl.OPENSSL_VERSION, "stdlib": sysconfig.get_path("stdlib")}))' + ].join(';') + const output = await runValidationCommand( + venvPython, + ['-I', '-c', script], + signal, + platform, + dependencies + ) + const result = JSON.parse(output.trim()) as { + version?: unknown + architecture?: unknown + ssl?: unknown + stdlib?: unknown + } + const expectedMachines = + arch === 'x64' ? ['x86_64', 'amd64', 'x64'] : ['aarch64', 'arm64'] + if ( + result.version !== version || + typeof result.architecture !== 'string' || + !expectedMachines.includes(result.architecture.toLocaleLowerCase()) || + typeof result.ssl !== 'string' || + result.ssl.length === 0 || + typeof result.stdlib !== 'string' || + result.stdlib.length === 0 + ) { + throw new Error('Managed Python runtime validation failed') + } + } catch (error) { + if (signal.aborted) { + throw signal.reason ?? error + } + throw error + } finally { + await rm(validationVenv, { recursive: true, force: true }).catch(() => undefined) + } +} + +export class LocalToolEnvironmentService { + private readonly platform: PythonArtifactPlatform + private readonly arch: PythonArtifactArch + private candidates: LocalToolEnvironmentSnapshot['candidates'] = [] + private diagnostics: LocalToolEnvironmentSnapshot['diagnostics'] = {} + private operation?: LocalToolPythonOperation + private operationController?: AbortController + private installOperation?: Promise + private launchEnvironment: Readonly = Object.freeze({}) + private listeners = new Set() + readonly launchEnvironmentProvider: LaunchEnvironmentProvider = () => + this.launchEnvironment + + constructor( + private readonly options: LocalToolEnvironmentServiceOptions, + private readonly dependencies: LocalToolEnvironmentServiceDependencies = {} + ) { + this.platform = options.platform ?? (process.platform as PythonArtifactPlatform) + this.arch = options.arch ?? (process.arch as PythonArtifactArch) + if (!['win32', 'darwin', 'linux'].includes(this.platform)) { + throw new Error(`Managed Python is unsupported on ${this.platform}`) + } + if (!['x64', 'arm64'].includes(this.arch)) { + throw new Error(`Managed Python is unsupported on ${this.arch}`) + } + } + + onProgress(listener: ProgressListener): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + private managedDirectory(version: string): string { + return join(this.options.managedPythonRoot, `python-${version}`) + } + + private async installedPython(version: string): Promise { + return existingFile(pythonExecutable(this.managedDirectory(version), this.platform)) + } + + private async settings(): Promise { + return (await this.options.settingsStore.get()).localToolEnvironment + } + + private async rebuildLaunchEnvironment( + signal?: AbortSignal + ): Promise { + const settings = await this.settings() + const catalog = await loadPythonArtifactCatalog( + this.options.pythonArtifactCatalogPath + ) + const generationDirectory = join( + this.options.binDirectory, + `generation-${randomUUID()}` + ) + try { + const environment = await createLocalToolEnvironment( + { + binDirectory: generationDirectory, + nodeSelection: settings.node, + pythonSelection: settings.python, + packagedNpmCliPath: this.options.packagedNpmCliPath, + packagedNpxCliPath: this.options.packagedNpxCliPath, + managedPythonExecutablePath: await this.installedPython( + catalog.pythonVersion + ), + baseEnvironment: this.options.baseEnvironment, + ...(signal ? { signal } : {}) + }, + { + ...this.dependencies.toolEnvironment, + ...(this.options.electronExecutablePath + ? { electronExecutablePath: this.options.electronExecutablePath } + : {}) + } + ) + this.launchEnvironment = Object.freeze({ ...environment.environment }) + return environment + } catch (error) { + await rm(generationDirectory, { + recursive: true, + force: true + }).catch(() => undefined) + throw error + } + } + + async initialize(): Promise { + await rm(this.options.binDirectory, { + recursive: true, + force: true + }) + await this.rebuildLaunchEnvironment() + } + + async getSnapshot(): Promise { + const catalog = await loadPythonArtifactCatalog( + this.options.pythonArtifactCatalogPath + ) + const executablePath = await this.installedPython(catalog.pythonVersion) + return { + settings: await this.settings(), + candidates: [...this.candidates], + diagnostics: { ...this.diagnostics }, + managedPython: { + version: catalog.pythonVersion, + installed: executablePath !== undefined, + ...(executablePath ? { executablePath } : {}), + ...(this.operation ? { operation: { ...this.operation } } : {}) + } + } + } + + private async emit(): Promise { + const progress = { snapshot: await this.getSnapshot() } + for (const listener of this.listeners) listener(progress) + } + + async updateSettings(input: unknown): Promise { + let settings = localToolEnvironmentSettingsSchema.parse(input) + const current = await this.settings() + for (const kind of ['node', 'python'] as const) { + const proposed = settings[kind] + const persisted = current[kind] + if ( + proposed.source === 'custom' && + (persisted.source !== 'custom' || + persisted.executablePath !== proposed.executablePath) + ) { + const candidate = await inspectLocalToolExecutable( + kind, + proposed.executablePath, + { + baseEnvironment: this.options.baseEnvironment ?? process.env + }, + this.dependencies.toolEnvironment + ) + if (!candidate) { + throw new Error(`Selected ${kind} executable is invalid`) + } + settings = { + ...settings, + [kind]: { + source: 'custom', + executablePath: candidate.executablePath + } + } + } + } + await this.options.settingsStore.update({ + localToolEnvironment: settings + }) + this.diagnostics = {} + try { + await this.rebuildLaunchEnvironment() + } catch (error) { + await this.options.settingsStore.update({ + localToolEnvironment: current + }) + await this.rebuildLaunchEnvironment().catch(() => undefined) + throw error + } + return this.getSnapshot() + } + + async refreshCandidates(): Promise { + this.candidates = await discoverCandidates( + this.options.baseEnvironment ?? process.env, + this.platform, + await this.settings(), + this.dependencies.toolEnvironment ?? {} + ) + return this.getSnapshot() + } + + async selectExecutable(kind: LocalToolKind): Promise { + const executablePath = await this.options.selectExecutable(kind) + if (!executablePath) return this.getSnapshot() + const current = await this.settings() + return this.updateSettings({ + ...current, + [kind]: { source: 'custom', executablePath } + }) + } + + async diagnose(target: LocalToolDiagnoseTarget): Promise { + const environment = await this.rebuildLaunchEnvironment( + this.operationController?.signal + ) + const resolved = Object.fromEntries( + Object.entries(environment.diagnostics.tools).map(([name, diagnostic]) => [ + name, + { + available: diagnostic.available, + source: diagnostic.source, + ...(diagnostic.version ? { version: diagnostic.version } : {}), + ...(diagnostic.executablePath + ? { executablePath: diagnostic.executablePath } + : {}), + detail: diagnostic.detail + } + ]) + ) + const names = + target === 'node' + ? ['node', 'npm', 'npx'] + : target === 'python' + ? ['python', 'python3', 'pip'] + : ['node', 'npm', 'npx', 'python', 'python3', 'pip'] + this.diagnostics = { + ...this.diagnostics, + ...Object.fromEntries(names.map((name) => [name, resolved[name]])) + } + return this.getSnapshot() + } + + private async setOperation(operation: LocalToolPythonOperation): Promise { + this.operation = operation + await this.emit() + } + + installPython(): Promise { + if (this.operationController) throw new Error('A Managed Python operation is already running') + const operation = this.runPythonInstall() + this.installOperation = operation + void operation.finally(() => { + if (this.installOperation === operation) { + this.installOperation = undefined + } + }).catch(() => undefined) + return operation + } + + private async runPythonInstall(): Promise { + const controller = new AbortController() + this.operationController = controller + let archivePath: string | undefined + try { + const settings = await this.settings() + const source: ArtifactDownloadSource = settings.artifactDownloadSource + const catalog = await loadPythonArtifactCatalog(this.options.pythonArtifactCatalogPath) + const artifact = selectPythonArtifact({ + catalog, + platform: this.platform, + arch: this.arch, + source + }) + await mkdir(this.options.managedPythonRoot, { recursive: true }) + await cleanupManagedPythonOperations(this.options.managedPythonRoot) + archivePath = join( + this.options.managedPythonRoot, + `.managed-python-download-${randomUUID()}` + ) + await this.setOperation({ + source, + phase: 'downloading', + receivedBytes: 0, + totalBytes: artifact.size + }) + await downloadPythonArtifact({ + artifact, + destinationPath: archivePath, + transport: this.dependencies.transport, + signal: controller.signal, + onProgress: (receivedBytes, totalBytes) => { + this.operation = { + source, + phase: 'downloading', + receivedBytes, + totalBytes + } + void this.emit().catch(() => undefined) + } + }) + const downloadedArchive = archivePath + const destination = await installManagedPython({ + rootDirectory: this.options.managedPythonRoot, + version: catalog.pythonVersion, + signal: controller.signal, + stage: async (stagingDirectory) => { + await this.setOperation({ source, phase: 'extracting' }) + await extractPythonArtifact({ + artifact, + archivePath: downloadedArchive, + destinationDirectory: join(stagingDirectory, 'python'), + signal: controller.signal + }) + }, + validate: async (stagingDirectory) => { + await this.setOperation({ source, phase: 'validating' }) + await validateManagedPython( + pythonExecutable(stagingDirectory, this.platform), + stagingDirectory, + catalog.pythonVersion, + this.arch, + controller.signal, + this.platform, + this.dependencies.toolEnvironment + ) + await this.setOperation({ source, phase: 'publishing' }) + } + }) + if (!(await existingFile(pythonExecutable(destination, this.platform)))) { + throw new Error('Managed Python publish did not produce an executable') + } + } finally { + if (archivePath) await rm(archivePath, { force: true }).catch(() => undefined) + this.operation = undefined + this.operationController = undefined + await this.emit() + } + return this.diagnose('python') + } + + cancelPython(): boolean { + if (!this.operationController) return false + this.operationController.abort( + new DOMException('Managed Python operation cancelled', 'AbortError') + ) + return true + } + + async removePython(): Promise { + if (this.operationController) throw new Error('A Managed Python operation is running') + const catalog = await loadPythonArtifactCatalog(this.options.pythonArtifactCatalogPath) + await removeManagedPython({ + rootDirectory: this.options.managedPythonRoot, + version: catalog.pythonVersion + }) + this.diagnostics = {} + await this.rebuildLaunchEnvironment() + await this.emit() + return this.getSnapshot() + } + + async dispose(): Promise { + this.cancelPython() + await this.installOperation?.catch(() => undefined) + await rm(this.options.binDirectory, { + recursive: true, + force: true + }) + } +} diff --git a/src/main/local-tool-environment/local-tool-environment.test.ts b/src/main/local-tool-environment/local-tool-environment.test.ts new file mode 100644 index 00000000..bb6ce8e2 --- /dev/null +++ b/src/main/local-tool-environment/local-tool-environment.test.ts @@ -0,0 +1,571 @@ +import { EventEmitter } from 'node:events' +import { chmod, mkdir, readFile, 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 { + createLocalToolEnvironment, + inspectLocalToolExecutable, + resolveNpmCliPaths, + type LocalToolProbeProcess, + type LocalToolProbeSpawn +} from './local-tool-environment' + +const temporaryDirectories: string[] = [] + +async function temporaryDirectory(name: string): Promise { + const path = join( + tmpdir(), + `goodbuddy-${name}-${process.pid}-${Math.random().toString(16).slice(2)}` + ) + await mkdir(path, { recursive: true }) + temporaryDirectories.push(path) + return path +} + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + const { rm } = await import('node:fs/promises') + await rm(directory, { recursive: true, force: true }) + } +}) + +type FakeProbe = LocalToolProbeProcess & EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter +} + +function probeSpawn( + outputFor: (command: string, args: string[]) => { + code?: number + stderr?: string + stdout?: string + } +): LocalToolProbeSpawn { + return (command, args) => { + const child = new EventEmitter() as unknown as FakeProbe + child.exitCode = null + child.killed = false + child.pid = 1234 + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.kill = vi.fn(() => { + child.killed = true + return true + }) + queueMicrotask(() => { + const result = outputFor(command, args) + if (result.stdout) { + child.stdout.emit('data', Buffer.from(result.stdout)) + } + if (result.stderr) { + child.stderr.emit('data', Buffer.from(result.stderr)) + } + child.exitCode = result.code ?? 0 + child.emit('close', child.exitCode) + }) + return child + } +} + +async function fixtureFiles(root: string): Promise<{ + electron: string + managedNpm: string + managedNpx: string + managedPython: string +}> { + const electron = join(root, 'Electron Runtime') + const managedNpm = join(root, 'packaged npm-cli.js') + const managedNpx = join(root, 'packaged npx-cli.js') + const managedPython = join(root, 'Managed Python') + await Promise.all( + [electron, managedNpm, managedNpx, managedPython].map((path) => + writeFile(path, 'fixture') + ) + ) + await Promise.all( + [electron, managedPython].map((path) => chmod(path, 0o700)) + ) + return { electron, managedNpm, managedNpx, managedPython } +} + +describe('local tool environment', () => { + it('creates managed shims and an immutable PATH snapshot', async () => { + const root = await temporaryDirectory("local-tools'posix") + const paths = await fixtureFiles(root) + const source = { + PATH: '/original/bin', + CUSTOM: 'kept', + ELECTRON_RUN_AS_NODE: 'inherited' + } + const spawnProcess = vi.fn( + probeSpawn((command, args) => { + if (args[0] === '-e') { + return { + stdout: '{"version":"22.14.0","architecture":"x64"}\n' + } + } + if (args[0] === '-I') { + return { + stdout: '{"version":"3.13.2","architecture":"x86_64"}\n' + } + } + if (args[0] === paths.managedNpm || args[0] === paths.managedNpx) { + return { stdout: '11.19.0\n' } + } + if (args.join(' ') === '-m pip --version') { + return { stdout: 'pip 25.0 from anywhere (python 3.13)\n' } + } + return { + stderr: + command === paths.managedPython + ? 'Python 3.13.2\n' + : 'v22.14.0\n' + } + }) + ) + const bin = join(root, 'private bin') + + const result = await createLocalToolEnvironment( + { + binDirectory: bin, + nodeSelection: { source: 'managed' }, + pythonSelection: { source: 'managed' }, + packagedNpmCliPath: paths.managedNpm, + packagedNpxCliPath: paths.managedNpx, + managedPythonExecutablePath: paths.managedPython, + baseEnvironment: source + }, + { + platform: 'linux', + electronExecutablePath: paths.electron, + spawnProcess + } + ) + + expect(result.environment.PATH).toBe(`${bin}:${source.PATH}`) + expect(result.environment.Path).toBeUndefined() + expect(result.environment.ELECTRON_RUN_AS_NODE).toBeUndefined() + expect(result.environment.OPENAI_API_KEY).toBeUndefined() + expect(Object.isFrozen(result.environment)).toBe(true) + expect(source.ELECTRON_RUN_AS_NODE).toBe('inherited') + for (const name of ['node', 'npm', 'npx'] as const) { + expect(result.diagnostics.tools[name]).toMatchObject({ + available: true, + source: 'managed' + }) + } + for (const name of ['python', 'python3', 'pip'] as const) { + expect(result.diagnostics.tools[name]).toMatchObject({ + available: true, + source: 'managed' + }) + } + + const nodeShim = await readFile(join(bin, 'node'), 'utf8') + const npmShim = await readFile(join(bin, 'npm'), 'utf8') + const pipShim = await readFile(join(bin, 'pip'), 'utf8') + expect(nodeShim).toContain( + `ELECTRON_RUN_AS_NODE=1 exec '${paths.electron.replaceAll("'", "'\\''")}' "$@"` + ) + expect(npmShim).toContain( + `npm_execpath='${paths.managedNpm.replaceAll("'", "'\\''")}'` + ) + expect(pipShim).toContain(" '-m' 'pip' \"$@\"") + if (process.platform !== 'win32') { + expect((await stat(join(bin, 'node'))).mode & 0o700).toBe(0o700) + } + expect( + spawnProcess.mock.calls.find(([command]) => command === paths.electron)?.[2] + .env.ELECTRON_RUN_AS_NODE + ).toBe('1') + }) + + it('keeps invalid custom selections unavailable without managed or PATH fallback', async () => { + const root = await temporaryDirectory('local-tools-no-fallback') + const paths = await fixtureFiles(root) + const spawnProcess = vi.fn(probeSpawn(() => ({ stdout: 'unexpected' }))) + const missingNode = join(root, 'missing-node') + const missingPython = join(root, 'missing-python') + + const result = await createLocalToolEnvironment( + { + binDirectory: join(root, 'bin'), + nodeSelection: { + source: 'custom', + executablePath: missingNode + }, + pythonSelection: { + source: 'custom', + executablePath: missingPython + }, + packagedNpmCliPath: paths.managedNpm, + packagedNpxCliPath: paths.managedNpx, + managedPythonExecutablePath: paths.managedPython, + baseEnvironment: { PATH: dirnameForPath(paths.electron) } + }, + { + platform: 'linux', + electronExecutablePath: paths.electron, + spawnProcess + } + ) + + expect(spawnProcess).not.toHaveBeenCalled() + for (const diagnostic of Object.values(result.diagnostics.tools)) { + expect(diagnostic).toMatchObject({ + available: false, + source: 'custom' + }) + } + expect(result.diagnostics.tools.node.executablePath).toBe(missingNode) + expect(result.diagnostics.tools.python.executablePath).toBe(missingPython) + }) + + it('does not replace custom runtimes that fail their probes', async () => { + const root = await temporaryDirectory('local-tools-probe-failure') + const paths = await fixtureFiles(root) + const customNode = join(root, 'custom-node') + const customPython = join(root, 'custom-python') + await Promise.all( + [customNode, customPython].map((path) => writeFile(path, 'fixture')) + ) + const spawnProcess = vi.fn( + probeSpawn(() => ({ code: 1, stderr: 'probe failed' })) + ) + + const result = await createLocalToolEnvironment( + { + binDirectory: join(root, 'bin'), + nodeSelection: { + source: 'custom', + executablePath: customNode + }, + pythonSelection: { + source: 'custom', + executablePath: customPython + }, + packagedNpmCliPath: paths.managedNpm, + packagedNpxCliPath: paths.managedNpx, + managedPythonExecutablePath: paths.managedPython + }, + { + platform: 'linux', + electronExecutablePath: paths.electron, + spawnProcess + } + ) + + expect(spawnProcess).toHaveBeenCalledTimes(2) + expect(spawnProcess.mock.calls.map(([command]) => command).sort()).toEqual( + [customNode, customPython].sort() + ) + expect(result.diagnostics.tools.node).toMatchObject({ + available: false, + source: 'custom', + executablePath: customNode + }) + expect(result.diagnostics.tools.python).toMatchObject({ + available: false, + source: 'custom', + executablePath: customPython + }) + }) + + it('does not mix packaged npm or npx into a custom Node selection', async () => { + const root = await temporaryDirectory('local-tools-no-mixing') + const paths = await fixtureFiles(root) + const customDirectory = join(root, 'custom-node') + await mkdir(customDirectory) + const customNode = join(customDirectory, 'node') + await writeFile(customNode, 'fixture') + const spawnProcess = vi.fn( + probeSpawn((command) => ({ + stdout: + command === customNode + ? '{"version":"20.18.1","architecture":"x64"}\n' + : 'unexpected\n' + })) + ) + + const result = await createLocalToolEnvironment( + { + binDirectory: join(root, 'bin'), + nodeSelection: { + source: 'custom', + executablePath: customNode + }, + pythonSelection: { source: 'managed' }, + packagedNpmCliPath: paths.managedNpm, + packagedNpxCliPath: paths.managedNpx, + baseEnvironment: { PATH: root } + }, + { + platform: 'linux', + electronExecutablePath: paths.electron, + spawnProcess + } + ) + + expect(result.diagnostics.tools.node).toMatchObject({ + available: true, + source: 'custom', + executablePath: customNode + }) + expect(result.diagnostics.tools.npm).toMatchObject({ + available: false, + source: 'custom' + }) + expect(result.diagnostics.tools.npx).toMatchObject({ + available: false, + source: 'custom' + }) + expect(spawnProcess).toHaveBeenCalledTimes(1) + expect(spawnProcess).not.toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([paths.managedNpm]), + expect.anything() + ) + }) + + it('uses only verified adjacent package managers with custom Node', async () => { + const root = await temporaryDirectory('local-tools-custom-adjacent') + const paths = await fixtureFiles(root) + const customDirectory = join(root, 'custom-node') + await mkdir(customDirectory) + const customNode = join(customDirectory, 'node') + const adjacentNpm = join(customDirectory, 'npm-cli.js') + const adjacentNpx = join(customDirectory, 'npx-cli.js') + await Promise.all( + [customNode, adjacentNpm, adjacentNpx].map((path) => + writeFile(path, 'fixture') + ) + ) + const result = await createLocalToolEnvironment( + { + binDirectory: join(root, 'bin'), + nodeSelection: { + source: 'custom', + executablePath: customNode + }, + pythonSelection: { source: 'managed' }, + packagedNpmCliPath: paths.managedNpm, + packagedNpxCliPath: paths.managedNpx + }, + { + platform: 'linux', + electronExecutablePath: paths.electron, + spawnProcess: probeSpawn((_command, args) => ({ + stdout: + args[0] === adjacentNpm || args[0] === adjacentNpx + ? '10.8.0\n' + : '{"version":"20.18.1","architecture":"x64"}\n' + })) + } + ) + + expect(result.diagnostics.tools.npm).toMatchObject({ + available: true, + source: 'custom', + executablePath: adjacentNpm + }) + expect(result.diagnostics.tools.npx).toMatchObject({ + available: true, + source: 'custom', + executablePath: adjacentNpx + }) + expect(await readFile(join(root, 'bin', 'npm'), 'utf8')).toContain( + `'${adjacentNpm}'` + ) + }) + + it('leaves an uninstalled managed Python unavailable without PATH probing', async () => { + const root = await temporaryDirectory('local-tools-python-uninstalled') + const paths = await fixtureFiles(root) + const spawnProcess = vi.fn( + probeSpawn((command, args) => ({ + stdout: + command === paths.electron && args[0] === '-e' + ? '{"version":"22.14.0","architecture":"x64"}\n' + : '11.19.0\n' + })) + ) + const result = await createLocalToolEnvironment( + { + binDirectory: join(root, 'bin'), + nodeSelection: { source: 'managed' }, + pythonSelection: { source: 'managed' }, + packagedNpmCliPath: paths.managedNpm, + packagedNpxCliPath: paths.managedNpx, + baseEnvironment: { PATH: dirnameForPath(paths.managedPython) } + }, + { + platform: 'linux', + electronExecutablePath: paths.electron, + spawnProcess + } + ) + + for (const name of ['python', 'python3', 'pip'] as const) { + expect(result.diagnostics.tools[name]).toMatchObject({ + available: false, + source: 'managed' + }) + } + expect(spawnProcess.mock.calls.every(([command]) => command !== paths.managedPython)).toBe(true) + }) + + it('writes Windows wrappers with wrapper-local Electron variables', async () => { + const root = await temporaryDirectory('local-tools-%-win') + const paths = await fixtureFiles(root) + const result = await createLocalToolEnvironment( + { + binDirectory: join(root, 'bin'), + nodeSelection: { source: 'managed' }, + pythonSelection: { + source: 'custom', + executablePath: paths.managedPython + }, + packagedNpmCliPath: paths.managedNpm, + packagedNpxCliPath: paths.managedNpx, + baseEnvironment: { Path: 'C:\\Windows\\System32' } + }, + { + platform: 'win32', + electronExecutablePath: paths.electron, + spawnProcess: probeSpawn((command, args) => ({ + stdout: + command === paths.managedPython + ? args.includes('pip') + ? 'pip 25.0\n' + : '{"version":"3.13.2","architecture":"amd64"}\n' + : args[0] === '-e' + ? '{"version":"22.14.0","architecture":"x64"}\n' + : '11.19.0\n' + })) + } + ) + + const nodeShim = await readFile(join(root, 'bin', 'node.cmd'), 'utf8') + const npmShim = await readFile(join(root, 'bin', 'npm.cmd'), 'utf8') + expect(nodeShim).toContain('set "ELECTRON_RUN_AS_NODE=1"') + expect(npmShim).toContain( + `set "npm_node_execpath=${paths.electron.replaceAll('%', '%%')}"` + ) + expect(result.environment.PATH).toBe( + `${join(root, 'bin')};C:\\Windows\\System32` + ) + }) + + it('cancels probes through process-tree cleanup', async () => { + const root = await temporaryDirectory('local-tools-cancel') + const paths = await fixtureFiles(root) + const terminate = vi.fn(async (child: LocalToolProbeProcess) => { + child.exitCode = 1 + }) + const hangingSpawn = vi.fn(() => { + const child = new EventEmitter() as unknown as FakeProbe + child.exitCode = null + child.killed = false + child.pid = 4321 + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.kill = vi.fn() + return child + }) + const controller = new AbortController() + const operation = createLocalToolEnvironment( + { + binDirectory: join(root, 'bin'), + nodeSelection: { source: 'managed' }, + pythonSelection: { source: 'managed' }, + packagedNpmCliPath: paths.managedNpm, + packagedNpxCliPath: paths.managedNpx, + managedPythonExecutablePath: paths.managedPython, + signal: controller.signal, + probeTimeoutMs: 60_000 + }, + { + platform: 'linux', + electronExecutablePath: paths.electron, + spawnProcess: hangingSpawn, + terminateProcessTree: terminate + } + ) + await vi.waitFor(() => expect(hangingSpawn).toHaveBeenCalledTimes(2)) + controller.abort(new Error('cancelled by test')) + + await expect(operation).rejects.toThrow('cancelled by test') + expect(terminate).toHaveBeenCalledTimes(2) + }) + + it('inspects executed runtime metadata and rejects Python 2', async () => { + const root = await temporaryDirectory('local-tools-inspection') + const executable = join(root, 'runtime') + await writeFile(executable, 'fixture') + + await expect( + inspectLocalToolExecutable( + 'node', + executable, + { baseEnvironment: { PATH: root, NODE_OPTIONS: '--inspect' } }, + { + platform: 'linux', + spawnProcess: probeSpawn((_command, args) => ({ + stdout: args.includes('-e') + ? '{"version":"22.14.0","architecture":"arm64"}\n' + : '' + })) + } + ) + ).resolves.toEqual({ + kind: 'node', + executablePath: await import('node:fs/promises').then(({ realpath }) => + realpath(executable) + ), + version: '22.14.0', + architecture: 'arm64' + }) + + await expect( + inspectLocalToolExecutable( + 'python', + executable, + { baseEnvironment: { PATH: root } }, + { + platform: 'linux', + spawnProcess: probeSpawn(() => ({ + stdout: '{"version":"2.7.18","architecture":"x86_64"}\n' + })) + } + ) + ).resolves.toBeUndefined() + }) + + it('resolves development and packaged npm CLI locations', () => { + expect( + resolveNpmCliPaths({ + appPath: '/app', + resourcesPath: '/resources', + packaged: false + }) + ).toEqual({ + npmCliPath: join('/app', 'node_modules', 'npm', 'bin', 'npm-cli.js'), + npxCliPath: join('/app', 'node_modules', 'npm', 'bin', 'npx-cli.js') + }) + expect( + resolveNpmCliPaths({ + appPath: '/app', + resourcesPath: '/resources', + packaged: true + }) + ).toEqual({ + npmCliPath: join('/resources', 'runtimes', 'npm', 'bin', 'npm-cli.js'), + npxCliPath: join('/resources', 'runtimes', 'npm', 'bin', 'npx-cli.js') + }) + }) +}) + +function dirnameForPath(path: string): string { + return path.slice(0, Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))) +} diff --git a/src/main/local-tool-environment/local-tool-environment.ts b/src/main/local-tool-environment/local-tool-environment.ts new file mode 100644 index 00000000..f4798cab --- /dev/null +++ b/src/main/local-tool-environment/local-tool-environment.ts @@ -0,0 +1,815 @@ +import { chmod, mkdir, realpath, stat, unlink, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, join } from 'node:path' +import spawn from 'cross-spawn' +import type { + LocalToolCandidate, + LocalToolKind, + LocalToolRuntimeSelection +} from '../../shared/local-tool-environment-contracts' +import { + terminateProcessTreeAndWait, + type WaitableProcessTreeChild +} from '../agent/child-process-termination' + +const DEFAULT_PROBE_TIMEOUT_MS = 3_000 +const DEFAULT_PROBE_OUTPUT_LIMIT = 8 * 1024 +const DEFAULT_TERMINATION_WAIT_MS = 500 + +export type LocalToolName = + | 'node' + | 'npm' + | 'npx' + | 'python' + | 'python3' + | 'pip' + +export type LocalToolSource = LocalToolRuntimeSelection['source'] + +export type LocalToolDiagnostic = { + available: boolean + source: LocalToolSource + version?: string + executablePath?: string + shimPath?: string + detail: string +} + +export type LocalToolEnvironmentDiagnostics = { + binDirectory: string + tools: Readonly> + warnings: readonly string[] +} + +export type LocalToolEnvironment = { + binDirectory: string + environment: Readonly + diagnostics: LocalToolEnvironmentDiagnostics +} + +type ProbeOutput = { + on(event: 'data', listener: (chunk: Buffer | string) => void): unknown +} + +export type LocalToolProbeProcess = WaitableProcessTreeChild & { + stdout?: ProbeOutput | null + stderr?: ProbeOutput | null +} + +export type LocalToolProbeSpawn = ( + command: string, + args: string[], + options: { + detached: boolean + env: NodeJS.ProcessEnv + shell: false + stdio: ['ignore', 'pipe', 'pipe'] + windowsHide: true + } +) => LocalToolProbeProcess + +export type LocalToolEnvironmentOptions = { + binDirectory: string + nodeSelection: LocalToolRuntimeSelection + pythonSelection: LocalToolRuntimeSelection + packagedNpmCliPath: string + packagedNpxCliPath: string + managedPythonExecutablePath?: string + baseEnvironment?: NodeJS.ProcessEnv + signal?: AbortSignal + probeTimeoutMs?: number + probeOutputLimit?: number + terminationWaitMs?: number +} + +export type LocalToolEnvironmentDependencies = { + platform?: NodeJS.Platform + electronExecutablePath?: string + spawnProcess?: LocalToolProbeSpawn + terminateProcessTree?: typeof terminateProcessTreeAndWait +} + +type ProbeResult = + | { ok: true; output: string } + | { + ok: false + reason: 'cancelled' | 'exit' | 'output-limit' | 'spawn' | 'timeout' + } + +type ResolvedRuntime = { + path: string + source: LocalToolSource + managedElectron?: boolean + version?: string +} + +type PackageManagerInvocation = { + command: string + argsPrefix: readonly string[] + path: string + source: LocalToolSource + managedElectron?: boolean + version?: string +} + +function quotePosix(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'` +} + +function quoteCmd(value: string): string { + return `"${value.replaceAll('%', '%%')}"` +} + +function stripUnsafeCharacters(value: string): string { + let result = '' + let inEscapeSequence = false + for (const character of value) { + const point = character.codePointAt(0) ?? 0 + if (inEscapeSequence) { + if (point >= 64 && point <= 126) { + inEscapeSequence = false + } + continue + } + if (point === 27) { + inEscapeSequence = true + } else if (point >= 32 && point !== 127) { + result += character + } + } + return result +} + +function safeVersion(output: string): string | undefined { + const firstLine = output + .split(/\r?\n/u) + .map((line) => stripUnsafeCharacters(line).trim()) + .find(Boolean) + if (!firstLine) { + return undefined + } + const version = firstLine.match( + /\bv?(\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?)\b/u + ) + return (version?.[1] ?? firstLine).slice(0, 160) +} + +function abortError(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException('Operation aborted', 'AbortError') +} + +async function canonicalFile(filePath: string): Promise { + if (!isAbsolute(filePath)) { + return undefined + } + try { + const path = await realpath(filePath) + return (await stat(path)).isFile() ? path : undefined + } catch { + return undefined + } +} + +function probeExecutable( + command: string, + args: string[], + environment: NodeJS.ProcessEnv, + signal: AbortSignal | undefined, + dependencies: LocalToolEnvironmentDependencies, + options: LocalToolEnvironmentOptions +): Promise { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve({ ok: false, reason: 'cancelled' }) + return + } + const platform = dependencies.platform ?? process.platform + let child: LocalToolProbeProcess + try { + child = (dependencies.spawnProcess ?? spawn)(command, args, { + detached: platform !== 'win32', + env: environment, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + }) + } catch { + resolve({ ok: false, reason: 'spawn' }) + return + } + + let settled = false + let cleaning = false + let output = '' + let outputBytes = 0 + const finish = (result: ProbeResult): void => { + if (settled || cleaning) { + return + } + settled = true + clearTimeout(timeout) + signal?.removeEventListener('abort', onAbort) + resolve(result) + } + const failWithCleanup = ( + reason: Extract['reason'] + ): void => { + if (settled || cleaning) { + return + } + cleaning = true + clearTimeout(timeout) + signal?.removeEventListener('abort', onAbort) + void ( + dependencies.terminateProcessTree ?? terminateProcessTreeAndWait + )(child, { + platform, + processGroup: platform !== 'win32', + signal: 'SIGKILL', + waitMs: options.terminationWaitMs ?? DEFAULT_TERMINATION_WAIT_MS + }).then( + () => { + settled = true + resolve({ ok: false, reason }) + }, + () => { + settled = true + resolve({ ok: false, reason }) + } + ) + } + const onAbort = (): void => failWithCleanup('cancelled') + const timeout = setTimeout( + () => failWithCleanup('timeout'), + options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS + ) + signal?.addEventListener('abort', onAbort, { once: true }) + + const collect = (chunk: Buffer | string): void => { + const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + outputBytes += value.byteLength + if (outputBytes > (options.probeOutputLimit ?? DEFAULT_PROBE_OUTPUT_LIMIT)) { + failWithCleanup('output-limit') + return + } + output += value.toString('utf8') + } + child.stdout?.on('data', collect) + child.stderr?.on('data', collect) + child.once('error', () => finish({ ok: false, reason: 'spawn' })) + child.once('close', (code: unknown) => { + if (cleaning) { + return + } + finish( + code === 0 + ? { ok: true, output } + : { ok: false, reason: 'exit' } + ) + }) + }) +} + +export type LocalToolExecutableInspectionOptions = { + baseEnvironment: NodeJS.ProcessEnv + managedElectron?: boolean + signal?: AbortSignal + probeTimeoutMs?: number + probeOutputLimit?: number + terminationWaitMs?: number +} + +export async function inspectLocalToolExecutable( + kind: LocalToolKind, + executablePath: string, + options: LocalToolExecutableInspectionOptions, + dependencies: LocalToolEnvironmentDependencies = {} +): Promise { + const path = await canonicalFile(executablePath) + if (!path) { + return undefined + } + const script = + kind === 'node' + ? 'console.log(JSON.stringify({version:process.versions.node,architecture:process.arch}))' + : [ + 'import json, platform, sys', + 'print(json.dumps({"version": platform.python_version(), "architecture": platform.machine()}))' + ].join(';') + const probe = await probeExecutable( + path, + kind === 'node' ? ['-e', script] : ['-I', '-c', script], + runtimeProbeEnvironment( + options.baseEnvironment, + options.managedElectron === true + ), + options.signal, + dependencies, + { + binDirectory: dirname(path), + nodeSelection: { source: 'managed' }, + pythonSelection: { source: 'managed' }, + packagedNpmCliPath: path, + packagedNpxCliPath: path, + ...options + } + ) + if (!probe.ok) { + if (probe.reason === 'cancelled') { + throw abortError(options.signal!) + } + return undefined + } + try { + const parsed = JSON.parse(probe.output.trim()) as { + version?: unknown + architecture?: unknown + } + if ( + typeof parsed.version !== 'string' || + !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(parsed.version) || + (kind === 'python' && !parsed.version.startsWith('3.')) || + typeof parsed.architecture !== 'string' + ) { + return undefined + } + const architecture = parsed.architecture.trim().toLocaleLowerCase() + if (!/^[0-9a-z][0-9a-z._-]{0,63}$/u.test(architecture)) { + return undefined + } + return { + kind, + executablePath: path, + version: parsed.version, + architecture + } + } catch { + return undefined + } +} + +function runtimeProbeEnvironment( + base: NodeJS.ProcessEnv, + managedElectron: boolean +): NodeJS.ProcessEnv { + const environment = { ...base } + for (const key of Object.keys(environment)) { + if ( + [ + 'electron_run_as_node', + 'node_options', + 'node_path', + 'pythonhome', + 'pythoninspect', + 'pythonpath', + 'pythonstartup', + 'pythonwarnings' + ].includes(key.toLocaleLowerCase()) + ) { + delete environment[key] + } + } + if (managedElectron) { + environment.ELECTRON_RUN_AS_NODE = '1' + } + return environment +} + +async function resolveRuntime( + kind: LocalToolKind, + selection: LocalToolRuntimeSelection, + managedExecutablePath: string | undefined, + managedElectron: boolean, + options: LocalToolEnvironmentOptions, + dependencies: LocalToolEnvironmentDependencies, + base: NodeJS.ProcessEnv +): Promise { + const selectedPath = + selection.source === 'custom' + ? selection.executablePath + : managedExecutablePath + if (!selectedPath) { + return undefined + } + const inspected = await inspectLocalToolExecutable( + kind, + selectedPath, + { + baseEnvironment: base, + managedElectron, + ...(options.signal ? { signal: options.signal } : {}), + ...(options.probeTimeoutMs + ? { probeTimeoutMs: options.probeTimeoutMs } + : {}), + ...(options.probeOutputLimit + ? { probeOutputLimit: options.probeOutputLimit } + : {}), + ...(options.terminationWaitMs + ? { terminationWaitMs: options.terminationWaitMs } + : {}) + }, + dependencies + ) + if (!inspected) { + return undefined + } + return { + path: inspected.executablePath, + source: selection.source, + managedElectron, + version: inspected.version + } +} + +function adjacentPackageManagerNames( + name: 'npm' | 'npx', + platform: NodeJS.Platform +): readonly string[] { + return platform === 'win32' + ? [`${name}.cmd`, `${name}.exe`, `${name}-cli.js`] + : [name, `${name}-cli.js`] +} + +async function resolvePackageManager( + name: 'npm' | 'npx', + node: ResolvedRuntime, + packagedCliPath: string, + options: LocalToolEnvironmentOptions, + dependencies: LocalToolEnvironmentDependencies, + base: NodeJS.ProcessEnv +): Promise { + const platform = dependencies.platform ?? process.platform + const candidates = + node.source === 'managed' + ? [{ path: packagedCliPath, cli: true }] + : adjacentPackageManagerNames(name, platform).map((fileName) => ({ + path: join(dirname(node.path), fileName), + cli: fileName.endsWith('-cli.js') + })) + + for (const candidate of candidates) { + const path = await canonicalFile(candidate.path) + if (!path) { + continue + } + const command = candidate.cli ? node.path : path + const argsPrefix = candidate.cli ? [path] : [] + const probe = await probeExecutable( + command, + [...argsPrefix, '--version'], + runtimeProbeEnvironment(base, node.managedElectron === true), + options.signal, + dependencies, + options + ) + if (!probe.ok) { + if (probe.reason === 'cancelled') { + throw abortError(options.signal!) + } + continue + } + return { + command, + argsPrefix, + path, + source: node.source, + managedElectron: node.managedElectron, + version: safeVersion(probe.output) + } + } + return undefined +} + +function shimFileName(name: LocalToolName, platform: NodeJS.Platform): string { + return platform === 'win32' ? `${name}.cmd` : name +} + +function commandLines(input: { + platform: NodeJS.Platform + command: string + argsPrefix?: readonly string[] + managedElectron?: boolean + packageManagerPath?: string + nodePath?: string +}): string { + const prefix = input.argsPrefix ?? [] + if (input.platform === 'win32') { + const lines = ['@echo off', 'setlocal'] + if (input.managedElectron) { + lines.push('set "ELECTRON_RUN_AS_NODE=1"') + } + if (input.packageManagerPath && input.nodePath) { + lines.push( + `set "npm_execpath=${input.packageManagerPath.replaceAll('%', '%%')}"` + ) + lines.push( + `set "npm_node_execpath=${input.nodePath.replaceAll('%', '%%')}"` + ) + } + lines.push( + [quoteCmd(input.command), ...prefix.map(quoteCmd), '%*'].join(' ') + ) + return `${lines.join('\r\n')}\r\n` + } + const assignments: string[] = [] + if (input.managedElectron) { + assignments.push('ELECTRON_RUN_AS_NODE=1') + } + if (input.packageManagerPath && input.nodePath) { + assignments.push(`npm_execpath=${quotePosix(input.packageManagerPath)}`) + assignments.push(`npm_node_execpath=${quotePosix(input.nodePath)}`) + } + return [ + '#!/bin/sh', + `${assignments.length ? `${assignments.join(' ')} ` : ''}exec ${quotePosix( + input.command + )}${prefix.length ? ` ${prefix.map(quotePosix).join(' ')}` : ''} "$@"`, + '' + ].join('\n') +} + +async function writeShim( + path: string, + content: string, + platform: NodeJS.Platform +): Promise { + await writeFile(path, content, { encoding: 'utf8', mode: 0o700 }) + if (platform !== 'win32') { + await chmod(path, 0o700) + } +} + +function unavailable( + source: LocalToolSource, + detail: string, + executablePath?: string +): LocalToolDiagnostic { + return { available: false, source, executablePath, detail } +} + +function available( + source: LocalToolSource, + executablePath: string, + version: string | undefined, + shimPath: string +): LocalToolDiagnostic { + return { + available: true, + source, + version, + executablePath, + shimPath, + detail: `${source} tool is ready${version ? ` (${version})` : ''}.` + } +} + +function selectedPath( + selection: LocalToolRuntimeSelection, + managedPath: string | undefined +): string | undefined { + return selection.source === 'custom' + ? selection.executablePath + : managedPath +} + +function launchEnvironmentSnapshot( + binDirectory: string, + source: NodeJS.ProcessEnv, + platform: NodeJS.Platform +): Readonly { + const inheritedPath = + source.PATH ?? source.Path ?? source.path ?? '' + const path = inheritedPath + ? `${binDirectory}${platform === 'win32' ? ';' : ':'}${inheritedPath}` + : binDirectory + return Object.freeze({ PATH: path }) +} + +export function resolveNpmCliPaths(input: { + appPath: string + resourcesPath: string + packaged: boolean +}): { npmCliPath: string; npxCliPath: string } { + const bin = input.packaged + ? join(input.resourcesPath, 'runtimes', 'npm', 'bin') + : join(input.appPath, 'node_modules', 'npm', 'bin') + return { + npmCliPath: join(bin, 'npm-cli.js'), + npxCliPath: join(bin, 'npx-cli.js') + } +} + +export async function createLocalToolEnvironment( + options: LocalToolEnvironmentOptions, + dependencies: LocalToolEnvironmentDependencies = {} +): Promise { + if (!isAbsolute(options.binDirectory)) { + throw new Error('Local tool bin directory must be absolute.') + } + options.signal?.throwIfAborted() + const platform = dependencies.platform ?? process.platform + const base = { ...(options.baseEnvironment ?? process.env) } + delete base.ELECTRON_RUN_AS_NODE + const warnings: string[] = [] + await mkdir(options.binDirectory, { recursive: true, mode: 0o700 }) + if (platform !== 'win32') { + await chmod(options.binDirectory, 0o700) + } + + const shimPaths = Object.fromEntries( + (['node', 'npm', 'npx', 'python', 'python3', 'pip'] as const).map( + (name) => [name, join(options.binDirectory, shimFileName(name, platform))] + ) + ) as Record + await Promise.all( + Object.values(shimPaths).map((path) => + unlink(path).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') { + throw error + } + }) + ) + ) + + const managedNodePath = + dependencies.electronExecutablePath ?? process.execPath + const [node, python] = await Promise.all([ + resolveRuntime( + 'node', + options.nodeSelection, + managedNodePath, + options.nodeSelection.source === 'managed', + options, + dependencies, + base + ), + resolveRuntime( + 'python', + options.pythonSelection, + options.managedPythonExecutablePath, + false, + options, + dependencies, + base + ) + ]) + options.signal?.throwIfAborted() + + const nodeSelectedPath = selectedPath( + options.nodeSelection, + managedNodePath + ) + const pythonSelectedPath = selectedPath( + options.pythonSelection, + options.managedPythonExecutablePath + ) + const diagnostics: Record = { + node: unavailable( + options.nodeSelection.source, + 'The selected Node runtime is unavailable or failed its version probe.', + nodeSelectedPath + ), + npm: unavailable( + options.nodeSelection.source, + 'No verified npm from the selected Node installation is available.' + ), + npx: unavailable( + options.nodeSelection.source, + 'No verified npx from the selected Node installation is available.' + ), + python: unavailable( + options.pythonSelection.source, + 'The selected Python runtime is not installed, invalid, or failed its version probe.', + pythonSelectedPath + ), + python3: unavailable( + options.pythonSelection.source, + 'The selected Python runtime is not installed, invalid, or failed its version probe.', + pythonSelectedPath + ), + pip: unavailable( + options.pythonSelection.source, + 'The selected Python runtime does not provide a verified pip module.', + pythonSelectedPath + ) + } + + if (node) { + await writeShim( + shimPaths.node, + commandLines({ + platform, + command: node.path, + managedElectron: node.managedElectron + }), + platform + ) + diagnostics.node = available( + node.source, + node.path, + node.version, + shimPaths.node + ) + + const [npm, npx] = await Promise.all([ + resolvePackageManager( + 'npm', + node, + options.packagedNpmCliPath, + options, + dependencies, + base + ), + resolvePackageManager( + 'npx', + node, + options.packagedNpxCliPath, + options, + dependencies, + base + ) + ]) + options.signal?.throwIfAborted() + for (const [name, manager] of [ + ['npm', npm], + ['npx', npx] + ] as const) { + if (!manager) { + continue + } + await writeShim( + shimPaths[name], + commandLines({ + platform, + command: manager.command, + argsPrefix: manager.argsPrefix, + managedElectron: manager.managedElectron, + packageManagerPath: manager.path, + nodePath: node.path + }), + platform + ) + diagnostics[name] = available( + manager.source, + manager.path, + manager.version, + shimPaths[name] + ) + } + } + + if (python) { + for (const name of ['python', 'python3'] as const) { + await writeShim( + shimPaths[name], + commandLines({ platform, command: python.path }), + platform + ) + diagnostics[name] = available( + python.source, + python.path, + python.version, + shimPaths[name] + ) + } + const pipProbe = await probeExecutable( + python.path, + ['-m', 'pip', '--version'], + base, + options.signal, + dependencies, + options + ) + options.signal?.throwIfAborted() + if (pipProbe.ok) { + await writeShim( + shimPaths.pip, + commandLines({ + platform, + command: python.path, + argsPrefix: ['-m', 'pip'] + }), + platform + ) + diagnostics.pip = available( + python.source, + python.path, + safeVersion(pipProbe.output), + shimPaths.pip + ) + } + } + + return { + binDirectory: options.binDirectory, + environment: launchEnvironmentSnapshot( + options.binDirectory, + base, + platform + ), + diagnostics: { + binDirectory: options.binDirectory, + tools: Object.freeze(diagnostics), + warnings: Object.freeze(warnings) + } + } +} diff --git a/src/main/local-tool-environment/managed-python-install.test.ts b/src/main/local-tool-environment/managed-python-install.test.ts new file mode 100644 index 00000000..50d7d924 --- /dev/null +++ b/src/main/local-tool-environment/managed-python-install.test.ts @@ -0,0 +1,87 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + installManagedPython, + removeManagedPython +} from './managed-python-install' + +const temporaryDirectories: string[] = [] + +async function root(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-python-install-')) + temporaryDirectories.push(directory) + return join(directory, 'managed') +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + )) +}) + +describe('Managed Python staged operations', () => { + it('publishes only after validation succeeds', async () => { + const rootDirectory = await root() + const installed = await installManagedPython({ + rootDirectory, + version: '3.13.15', + stage: async (directory) => { + await writeFile(join(directory, 'python'), 'new') + }, + validate: async (directory) => { + expect(await readFile(join(directory, 'python'), 'utf8')).toBe('new') + } + }) + expect(await readFile(join(installed, 'python'), 'utf8')).toBe('new') + }) + + it('preserves an old same-version install after failed validation', async () => { + const rootDirectory = await root() + await installManagedPython({ + rootDirectory, + version: '3.13.15', + stage: (directory) => writeFile(join(directory, 'python'), 'old'), + validate: async () => undefined + }) + await expect(installManagedPython({ + rootDirectory, + version: '3.13.15', + stage: (directory) => writeFile(join(directory, 'python'), 'bad'), + validate: async () => { + throw new Error('probe failed') + } + })).rejects.toThrow('probe failed') + expect(await readFile( + join(rootDirectory, 'python-3.13.15', 'python'), 'utf8' + )).toBe('old') + }) + + it('refuses to adopt or remove content outside an owned root', async () => { + const rootDirectory = await root() + await mkdir(rootDirectory) + await writeFile(join(rootDirectory, 'unrelated'), 'keep') + await expect(installManagedPython({ + rootDirectory, + version: '3.13.15', + stage: async () => undefined, + validate: async () => undefined + })).rejects.toThrow(/non-empty/u) + expect(await readFile(join(rootDirectory, 'unrelated'), 'utf8')).toBe('keep') + }) + + it('removes only a selected managed version', async () => { + const rootDirectory = await root() + await installManagedPython({ + rootDirectory, + version: '3.13.15', + stage: (directory) => writeFile(join(directory, 'python'), 'ok'), + validate: async () => undefined + }) + await removeManagedPython({ rootDirectory, version: '3.13.15' }) + await expect(readFile( + join(rootDirectory, 'python-3.13.15', 'python') + )).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) diff --git a/src/main/local-tool-environment/managed-python-install.ts b/src/main/local-tool-environment/managed-python-install.ts new file mode 100644 index 00000000..560efd43 --- /dev/null +++ b/src/main/local-tool-environment/managed-python-install.ts @@ -0,0 +1,182 @@ +import { randomUUID } from 'node:crypto' +import { + lstat, + mkdir, + open, + readdir, + readFile, + rename, + rm +} from 'node:fs/promises' +import { dirname, resolve } from 'node:path' + +const OWNER_MARKER = '.goodbuddy-managed-python' +const OWNER_VALUE = 'GoodBuddy managed Python root\n' +const versionPattern = /^python-(\d+\.\d+\.\d+)$/u +const stagingPattern = /^\.managed-python-stage-[0-9a-f-]{36}$/u +const backupPattern = /^\.managed-python-backup-[0-9a-f-]{36}$/u + +function ensureNotAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw signal.reason instanceof Error + ? signal.reason + : new DOMException('The operation was aborted', 'AbortError') + } +} + +function child(root: string, name: string): string { + const path = resolve(root, name) + if (dirname(path) !== resolve(root)) { + throw new Error('Managed Python path escaped its owned root') + } + return path +} + +function versionName(version: string): string { + if (!/^\d+\.\d+\.\d+$/u.test(version)) { + throw new Error('Managed Python version is invalid') + } + return `python-${version}` +} + +async function ensureOwnedRoot(root: string): Promise { + await mkdir(root, { recursive: true }) + const marker = child(root, OWNER_MARKER) + try { + const value = await readFile(marker, 'utf8') + if (value !== OWNER_VALUE) { + throw new Error('Managed Python root has an invalid ownership marker') + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + const entries = await readdir(root) + if (entries.length !== 0) { + throw new Error('Refusing to adopt a non-empty Managed Python root', { + cause: error + }) + } + const handle = await open(marker, 'wx') + try { + await handle.writeFile(OWNER_VALUE) + await handle.sync() + } finally { + await handle.close() + } + } +} + +export async function cleanupManagedPythonOperations(root: string): Promise { + await ensureOwnedRoot(root) + for (const entry of await readdir(root, { withFileTypes: true })) { + if ( + (stagingPattern.test(entry.name) || backupPattern.test(entry.name)) && + entry.isDirectory() && + !entry.isSymbolicLink() + ) { + await rm(child(root, entry.name), { recursive: true, force: true }) + } + } +} + +async function pruneVersions( + root: string, + activeName: string, + keepVersions: number +): Promise { + const candidates: { name: string; mtimeMs: number }[] = [] + for (const entry of await readdir(root, { withFileTypes: true })) { + if ( + entry.name !== activeName && + versionPattern.test(entry.name) && + entry.isDirectory() && + !entry.isSymbolicLink() + ) { + const status = await lstat(child(root, entry.name)) + candidates.push({ name: entry.name, mtimeMs: status.mtimeMs }) + } + } + candidates.sort((left, right) => right.mtimeMs - left.mtimeMs) + for (const candidate of candidates.slice(Math.max(0, keepVersions - 1))) { + await rm(child(root, candidate.name), { recursive: true, force: true }) + } +} + +export async function installManagedPython(options: { + rootDirectory: string + version: string + stage: (stagingDirectory: string) => Promise + validate: (stagingDirectory: string) => Promise + signal?: AbortSignal + keepVersions?: number +}): Promise { + const root = resolve(options.rootDirectory) + const keepVersions = options.keepVersions ?? 1 + if (!Number.isInteger(keepVersions) || keepVersions < 1 || keepVersions > 3) { + throw new Error('Managed Python retained version count must be between 1 and 3') + } + await ensureOwnedRoot(root) + ensureNotAborted(options.signal) + const destinationName = versionName(options.version) + const destination = child(root, destinationName) + const staging = child(root, `.managed-python-stage-${randomUUID()}`) + const backup = child(root, `.managed-python-backup-${randomUUID()}`) + await mkdir(staging) + let movedExisting = false + try { + await options.stage(staging) + ensureNotAborted(options.signal) + await options.validate(staging) + ensureNotAborted(options.signal) + try { + const status = await lstat(destination) + if (!status.isDirectory() || status.isSymbolicLink()) { + throw new Error('Managed Python destination is not an owned directory') + } + await rename(destination, backup) + movedExisting = true + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } + try { + await rename(staging, destination) + } catch (error) { + if (movedExisting) { + await rename(backup, destination).catch(() => undefined) + } + throw error + } + if (movedExisting) { + await rm(backup, { recursive: true, force: true }) + } + await pruneVersions(root, destinationName, keepVersions) + return destination + } catch (error) { + await rm(staging, { recursive: true, force: true }).catch(() => undefined) + throw error + } +} + +export async function removeManagedPython(options: { + rootDirectory: string + version: string +}): Promise { + const root = resolve(options.rootDirectory) + await ensureOwnedRoot(root) + const destination = child(root, versionName(options.version)) + try { + const status = await lstat(destination) + if (!status.isDirectory() || status.isSymbolicLink()) { + throw new Error('Refusing to remove a non-directory Managed Python path') + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return + } + throw error + } + await rm(destination, { recursive: true, force: true }) +} diff --git a/src/main/local-tool-environment/python-artifact-catalog.test.ts b/src/main/local-tool-environment/python-artifact-catalog.test.ts new file mode 100644 index 00000000..4c5a0d80 --- /dev/null +++ b/src/main/local-tool-environment/python-artifact-catalog.test.ts @@ -0,0 +1,73 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + parsePythonArtifactCatalog, + selectPythonArtifact +} from './python-artifact-catalog' + +async function catalogValue(): Promise { + return JSON.parse(await readFile(resolve( + 'resources/tool-environment/managed-python-artifacts.json' + ), 'utf8')) as unknown +} + +describe('managed Python artifact catalog', () => { + it('loads exactly one pinned artifact for all six desktop targets', async () => { + const catalog = parsePythonArtifactCatalog(await catalogValue()) + expect(catalog.pythonVersion).toBe('3.13.15') + expect(catalog.artifacts.map((artifact) => + `${artifact.platform}/${artifact.arch}` + )).toEqual([ + 'win32/x64', + 'win32/arm64', + 'darwin/x64', + 'darwin/arm64', + 'linux/x64', + 'linux/arm64' + ]) + expect(catalog.artifacts.map(({ size, sha256 }) => ({ size, sha256 }))) + .toEqual([ + { size: 14391248, sha256: '05357887df50d3153efc681bdf432c321d3e2f9ce5788f99f4515b27e8fda0ac' }, + { size: 13762836, sha256: '3c1b1fdf56adc14634165df922d447520aefdc4a8411a34c34d8a062a4edf494' }, + { size: 24921256, sha256: 'd33d61f7f4982c94216e14a43599c75657b7d0839277fc72bc6dbac53e8229bc' }, + { size: 25140257, sha256: '149038dd0c194c25d4616d7e42a35f67f2edee96412788f74115819b6a4c8548' }, + { size: 34813635, sha256: '8af9a8214c71b2dd698005e39fab87aad02a994330508857da4e6d1ba7e6ddb6' }, + { size: 29222461, sha256: 'e5d0df1a6070a8614d808496e5ea28c727480e40ffcce1a94697a067f1690aa8' } + ]) + }) + + it('freezes the selected source without fallback', async () => { + const catalog = parsePythonArtifactCatalog(await catalogValue()) + const native = selectPythonArtifact({ + catalog, platform: 'linux', arch: 'arm64', source: 'native' + }) + const oss = selectPythonArtifact({ + catalog, platform: 'linux', arch: 'arm64', source: 'oss' + }) + expect(native.source).toBe('native') + expect(native.url).toContain('github.com/') + expect(oss.source).toBe('oss') + expect(oss.url).toContain(`/${oss.sha256}/`) + expect(oss.redirectHosts).toEqual([ + 'goodbuddy.oss-cn-beijing.aliyuncs.com' + ]) + }) + + it('rejects unknown fields and duplicate target rows', async () => { + const value = await catalogValue() as { + artifacts: Record[] + } + value.artifacts[0]!.surprise = true + expect(() => parsePythonArtifactCatalog(value)).toThrow() + + const duplicate = await catalogValue() as { + artifacts: Record[] + } + duplicate.artifacts[1]!.platform = 'win32' + duplicate.artifacts[1]!.arch = 'x64' + expect(() => parsePythonArtifactCatalog(duplicate)).toThrow( + /duplicate or unsupported target/u + ) + }) +}) diff --git a/src/main/local-tool-environment/python-artifact-catalog.ts b/src/main/local-tool-environment/python-artifact-catalog.ts new file mode 100644 index 00000000..3172142f --- /dev/null +++ b/src/main/local-tool-environment/python-artifact-catalog.ts @@ -0,0 +1,206 @@ +import { readFile } from 'node:fs/promises' +import { z } from 'zod' + +const platformSchema = z.enum(['win32', 'darwin', 'linux']) +const archSchema = z.enum(['x64', 'arm64']) +const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u) +const httpsUrlSchema = z.string().url().superRefine((value, context) => { + const url = new URL(value) + if ( + url.protocol !== 'https:' || + (url.port !== '' && url.port !== '443') || + url.username || + url.password || + url.hash + ) { + context.addIssue({ code: 'custom', message: 'must be a plain HTTPS URL' }) + } +}) + +const pinnedArtifacts: Record = { + 'win32/x64': { + fileName: 'python.3.13.15.nupkg', + size: 14391248, + sha256: '05357887df50d3153efc681bdf432c321d3e2f9ce5788f99f4515b27e8fda0ac' + }, + 'win32/arm64': { + fileName: 'pythonarm64.3.13.15.nupkg', + size: 13762836, + sha256: '3c1b1fdf56adc14634165df922d447520aefdc4a8411a34c34d8a062a4edf494' + }, + 'darwin/x64': { + fileName: 'cpython-3.13.15+20260825-x86_64-apple-darwin-install_only_stripped.tar.gz', + size: 24921256, + sha256: 'd33d61f7f4982c94216e14a43599c75657b7d0839277fc72bc6dbac53e8229bc' + }, + 'darwin/arm64': { + fileName: 'cpython-3.13.15+20260825-aarch64-apple-darwin-install_only_stripped.tar.gz', + size: 25140257, + sha256: '149038dd0c194c25d4616d7e42a35f67f2edee96412788f74115819b6a4c8548' + }, + 'linux/x64': { + fileName: 'cpython-3.13.15+20260825-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz', + size: 34813635, + sha256: '8af9a8214c71b2dd698005e39fab87aad02a994330508857da4e6d1ba7e6ddb6' + }, + 'linux/arm64': { + fileName: 'cpython-3.13.15+20260825-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz', + size: 29222461, + sha256: 'e5d0df1a6070a8614d808496e5ea28c727480e40ffcce1a94697a067f1690aa8' + } +} + +const artifactSchema = z.object({ + platform: platformSchema, + arch: archSchema, + archiveFormat: z.enum(['nuget-zip', 'tar.gz']), + payloadRoot: z.enum(['tools', 'python']), + fileName: z.string().min(1).max(255).regex(/^[^/\\\0]+$/u), + size: z.number().int().positive().safe(), + sha256: sha256Schema, + nativeUrl: httpsUrlSchema, + ossUrl: httpsUrlSchema +}).strict().superRefine((artifact, context) => { + const windows = artifact.platform === 'win32' + const key = `${artifact.platform}/${artifact.arch}` + const pinned = pinnedArtifacts[key] + if ( + !pinned || + artifact.fileName !== pinned.fileName || + artifact.size !== pinned.size || + artifact.sha256 !== pinned.sha256 + ) { + context.addIssue({ + code: 'custom', + message: `artifact does not match the CPython 3.13.15 pin for ${key}` + }) + } + if ( + (windows && ( + artifact.archiveFormat !== 'nuget-zip' || + artifact.payloadRoot !== 'tools' || + !artifact.fileName.endsWith('.nupkg') + )) || + (!windows && ( + artifact.archiveFormat !== 'tar.gz' || + artifact.payloadRoot !== 'python' || + !artifact.fileName.endsWith('.tar.gz') + )) + ) { + context.addIssue({ + code: 'custom', + message: 'archive format does not match the platform' + }) + } + const expectedOssPath = + `/tool-artifacts/python/3.13.15/${artifact.sha256}/` + + encodeURIComponent(artifact.fileName).replaceAll('%2F', '/') + const oss = new URL(artifact.ossUrl) + if ( + oss.hostname !== 'goodbuddy.oss-cn-beijing.aliyuncs.com' || + oss.pathname !== expectedOssPath + ) { + context.addIssue({ code: 'custom', message: 'OSS key is not immutable' }) + } + const expectedNativeUrl = windows + ? `https://api.nuget.org/v3-flatcontainer/${ + artifact.arch === 'x64' ? 'python' : 'pythonarm64' + }/3.13.15/${artifact.fileName}` + : 'https://github.com/astral-sh/python-build-standalone/releases/' + + `download/20260825/${encodeURIComponent(artifact.fileName)}` + if (artifact.nativeUrl !== expectedNativeUrl) { + context.addIssue({ + code: 'custom', + message: 'native URL does not match the pinned upstream release' + }) + } +}) + +const catalogSchema = z.object({ + formatVersion: z.literal(1), + pythonVersion: z.literal('3.13.15'), + artifacts: z.array(artifactSchema).length(6) +}).strict().superRefine((catalog, context) => { + const expected = new Set([ + 'win32/x64', 'win32/arm64', + 'darwin/x64', 'darwin/arm64', + 'linux/x64', 'linux/arm64' + ]) + for (const artifact of catalog.artifacts) { + const key = `${artifact.platform}/${artifact.arch}` + if (!expected.delete(key)) { + context.addIssue({ + code: 'custom', + path: ['artifacts'], + message: `duplicate or unsupported target: ${key}` + }) + } + } + if (expected.size > 0) { + context.addIssue({ + code: 'custom', + path: ['artifacts'], + message: `missing targets: ${[...expected].join(', ')}` + }) + } +}) + +export type PythonArtifactSource = 'native' | 'oss' +export type PythonArtifactPlatform = z.infer +export type PythonArtifactArch = z.infer +export type PythonArtifactCatalog = z.infer +export type PythonArtifact = z.infer & { + source: PythonArtifactSource + url: string + redirectHosts: readonly string[] +} + +export function parsePythonArtifactCatalog(value: unknown): PythonArtifactCatalog { + return catalogSchema.parse(value) +} + +export async function loadPythonArtifactCatalog( + catalogPath: string +): Promise { + const text = await readFile(catalogPath, 'utf8') + let value: unknown + try { + value = JSON.parse(text) + } catch (error) { + throw new Error('Managed Python artifact catalog is not valid JSON', { + cause: error + }) + } + return parsePythonArtifactCatalog(value) +} + +export function selectPythonArtifact(options: { + catalog: PythonArtifactCatalog + platform: PythonArtifactPlatform + arch: PythonArtifactArch + source: PythonArtifactSource +}): PythonArtifact { + const artifact = options.catalog.artifacts.find( + (candidate) => + candidate.platform === options.platform && + candidate.arch === options.arch + ) + if (!artifact) { + throw new Error(`Managed Python is unavailable for ${options.platform}/${options.arch}`) + } + const url = options.source === 'native' + ? artifact.nativeUrl + : artifact.ossUrl + const host = new URL(url).hostname + const redirectHosts = + options.source === 'oss' + ? [host] + : artifact.platform === 'win32' + ? [host, 'globalcdn.nuget.org'] + : [host, 'objects.githubusercontent.com', 'release-assets.githubusercontent.com'] + return { ...artifact, source: options.source, url, redirectHosts } +} diff --git a/src/main/local-tool-environment/python-artifact-download.test.ts b/src/main/local-tool-environment/python-artifact-download.test.ts new file mode 100644 index 00000000..b2ba0f5d --- /dev/null +++ b/src/main/local-tool-environment/python-artifact-download.test.ts @@ -0,0 +1,94 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PythonArtifact } from './python-artifact-catalog' +import { downloadPythonArtifact } from './python-artifact-download' + +const temporaryDirectories: string[] = [] + +async function temporaryPath(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-python-download-')) + temporaryDirectories.push(directory) + return join(directory, 'artifact') +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + )) +}) + +function artifact(bytes: Uint8Array): PythonArtifact { + return { + platform: 'linux', + arch: 'x64', + archiveFormat: 'tar.gz', + payloadRoot: 'python', + fileName: 'python.tar.gz', + size: bytes.byteLength, + sha256: createHash('sha256').update(bytes).digest('hex'), + nativeUrl: 'https://download.example/python.tar.gz', + ossUrl: 'https://oss.example/python.tar.gz', + source: 'native', + url: 'https://download.example/python.tar.gz', + redirectHosts: ['download.example', 'cdn.example'] + } +} + +describe('Managed Python download', () => { + it('follows only bounded declared redirects and verifies bytes', async () => { + const bytes = Buffer.from('verified Python archive') + const transport = vi.fn() + .mockResolvedValueOnce(new Response(null, { + status: 302, + headers: { location: 'https://cdn.example/python.tar.gz' } + })) + .mockResolvedValueOnce(new Response(bytes, { + status: 200, + headers: { 'content-length': String(bytes.byteLength) } + })) + const destinationPath = await temporaryPath() + await downloadPythonArtifact({ + artifact: artifact(bytes), + destinationPath, + transport + }) + expect(await readFile(destinationPath)).toEqual(bytes) + expect(transport).toHaveBeenCalledTimes(2) + }) + + it('removes a partial on hash mismatch', async () => { + const bytes = Buffer.from('wrong bytes') + const expected = artifact(Buffer.from('right bytes')) + expected.size = bytes.byteLength + const destinationPath = await temporaryPath() + await expect(downloadPythonArtifact({ + artifact: expected, + destinationPath, + transport: vi.fn().mockResolvedValue( + new Response(bytes, { status: 200 }) + ) + })).rejects.toThrow(/integrity/u) + await expect(readFile(destinationPath)).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('rejects an undeclared redirect host without a fallback request', async () => { + const bytes = Buffer.from('archive') + const transport = vi.fn().mockResolvedValue( + new Response(null, { + status: 302, + headers: { location: 'https://attacker.example/archive' } + }) + ) + await expect(downloadPythonArtifact({ + artifact: artifact(bytes), + destinationPath: await temporaryPath(), + transport + })).rejects.toThrow(/undeclared host/u) + expect(transport).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/local-tool-environment/python-artifact-download.ts b/src/main/local-tool-environment/python-artifact-download.ts new file mode 100644 index 00000000..a93b5ed0 --- /dev/null +++ b/src/main/local-tool-environment/python-artifact-download.ts @@ -0,0 +1,136 @@ +import { createHash } from 'node:crypto' +import { open, rm } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' +import type { PythonArtifact } from './python-artifact-catalog' + +const redirectStatuses = new Set([301, 302, 303, 307, 308]) +const MAX_REDIRECTS = 3 + +function ensureNotAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw signal.reason instanceof Error + ? signal.reason + : new DOMException('The operation was aborted', 'AbortError') + } +} + +function secureUrl(value: string): URL { + const url = new URL(value) + if ( + url.protocol !== 'https:' || + (url.port !== '' && url.port !== '443') || + url.username || + url.password || + url.hash + ) { + throw new Error('Managed Python download URL must be plain HTTPS') + } + return url +} + +async function close(handle: FileHandle | undefined): Promise { + await handle?.close().catch(() => undefined) +} + +export async function downloadPythonArtifact(options: { + artifact: PythonArtifact + destinationPath: string + transport?: typeof fetch + signal?: AbortSignal + onProgress?: (receivedBytes: number, totalBytes: number) => void +}): Promise { + const transport = options.transport ?? fetch + const allowedHosts = new Set(options.artifact.redirectHosts) + let url = secureUrl(options.artifact.url) + if (!allowedHosts.has(url.hostname)) { + throw new Error('Managed Python initial download host is not declared') + } + let response: Response | undefined + for (let redirects = 0; ; redirects += 1) { + ensureNotAborted(options.signal) + response = await transport(url, { + method: 'GET', + redirect: 'manual', + credentials: 'omit', + cache: 'no-store', + signal: options.signal + }) + if (!redirectStatuses.has(response.status)) { + break + } + if (redirects >= MAX_REDIRECTS) { + await response.body?.cancel().catch(() => undefined) + throw new Error('Managed Python download has too many redirects') + } + const location = response.headers.get('location') + await response.body?.cancel().catch(() => undefined) + if (!location) { + throw new Error('Managed Python redirect has no location') + } + const next = secureUrl(new URL(location, url).toString()) + if (!allowedHosts.has(next.hostname)) { + throw new Error('Managed Python download redirected to an undeclared host') + } + url = next + } + if (!response.ok || !response.body) { + await response.body?.cancel().catch(() => undefined) + throw new Error(`Managed Python download failed with HTTP ${response.status}`) + } + const declaredLength = response.headers.get('content-length') + if ( + declaredLength !== null && + (!/^\d+$/u.test(declaredLength) || + Number(declaredLength) !== options.artifact.size) + ) { + await response.body.cancel().catch(() => undefined) + throw new Error('Managed Python download size does not match the catalog') + } + + let output: FileHandle | undefined + const hash = createHash('sha256') + let received = 0 + try { + output = await open(options.destinationPath, 'wx') + const reader = response.body.getReader() + while (true) { + ensureNotAborted(options.signal) + const { done, value } = await reader.read() + if (done) { + break + } + received += value.byteLength + if (received > options.artifact.size) { + await reader.cancel().catch(() => undefined) + throw new Error('Managed Python download exceeds the catalog size') + } + hash.update(value) + let offset = 0 + while (offset < value.byteLength) { + const { bytesWritten } = await output.write( + value, + offset, + value.byteLength - offset + ) + if (bytesWritten <= 0) { + throw new Error('Managed Python artifact write was incomplete') + } + offset += bytesWritten + } + options.onProgress?.(received, options.artifact.size) + } + if ( + received !== options.artifact.size || + hash.digest('hex') !== options.artifact.sha256 + ) { + throw new Error('Managed Python artifact integrity check failed') + } + await output.sync() + await close(output) + output = undefined + } catch (error) { + await close(output) + await rm(options.destinationPath, { force: true }).catch(() => undefined) + throw error + } +} diff --git a/src/main/local-tool-environment/python-artifact-extract.test.ts b/src/main/local-tool-environment/python-artifact-extract.test.ts new file mode 100644 index 00000000..2bdbdd22 --- /dev/null +++ b/src/main/local-tool-environment/python-artifact-extract.test.ts @@ -0,0 +1,168 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { gzipSync, zipSync } from 'fflate' +import { create } from 'tar' +import { afterEach, describe, expect, it } from 'vitest' +import { extractPythonArtifact } from './python-artifact-extract' + +const temporaryDirectories: string[] = [] + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-python-extract-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + )) +}) + +function tarField(value: string | number, length: number): Buffer { + const text = typeof value === 'number' ? value.toString(8) : value + const result = Buffer.alloc(length) + result.write(text.slice(0, length - 1), 0, 'ascii') + return result +} + +function tarArchive(entries: { + path: string + type?: '0' | '2' + content?: string + linkPath?: string + mode?: number +}[]): Buffer { + const parts: Buffer[] = [] + for (const item of entries) { + const content = Buffer.from(item.content ?? '') + const header = Buffer.alloc(512) + tarField(item.path, 100).copy(header, 0) + tarField(item.mode ?? 0o755, 8).copy(header, 100) + tarField(0, 8).copy(header, 108) + tarField(0, 8).copy(header, 116) + tarField(content.byteLength, 12).copy(header, 124) + tarField(0, 12).copy(header, 136) + header.fill(0x20, 148, 156) + header.write(item.type ?? '0', 156, 'ascii') + tarField(item.linkPath ?? '', 100).copy(header, 157) + header.write('ustar\0', 257, 'ascii') + header.write('00', 263, 'ascii') + let checksum = 0 + for (const value of header) { + checksum += value + } + const checksumText = checksum.toString(8).padStart(6, '0') + header.write(`${checksumText}\0 `, 148, 'ascii') + parts.push(header, content) + const padding = (512 - content.byteLength % 512) % 512 + if (padding > 0) { + parts.push(Buffer.alloc(padding)) + } + } + parts.push(Buffer.alloc(1024)) + return Buffer.concat(parts) +} + +describe('Managed Python archive extraction', () => { + it('extracts only the tools payload root from a NuGet ZIP', async () => { + const directory = await temporaryDirectory() + const archivePath = join(directory, 'python.nupkg') + await writeFile(archivePath, zipSync({ + '[Content_Types].xml': Buffer.from(''), + 'package.nuspec': Buffer.from(''), + 'tools/python.exe': Buffer.from('python'), + 'tools/Lib/site.py': Buffer.from('site') + })) + const destinationDirectory = join(directory, 'install') + await extractPythonArtifact({ + artifact: { archiveFormat: 'nuget-zip', payloadRoot: 'tools' }, + archivePath, + destinationDirectory + }) + expect(await readFile(join(destinationDirectory, 'python.exe'), 'utf8')) + .toBe('python') + expect(await readFile(join(destinationDirectory, 'Lib/site.py'), 'utf8')) + .toBe('site') + await expect( + readFile(join(destinationDirectory, 'package.nuspec')) + ).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('rejects ZIP traversal before writing payload files', async () => { + const directory = await temporaryDirectory() + const archivePath = join(directory, 'bad.nupkg') + await writeFile(archivePath, zipSync({ + 'tools/python.exe': Buffer.from('python'), + 'tools/../escaped': Buffer.from('bad') + })) + await expect(extractPythonArtifact({ + artifact: { archiveFormat: 'nuget-zip', payloadRoot: 'tools' }, + archivePath, + destinationDirectory: join(directory, 'install') + })).rejects.toThrow(/Unsafe archive path/u) + await expect(readFile(join(directory, 'escaped'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('extracts an Astral-style python TAR root', async () => { + const directory = await temporaryDirectory() + const source = join(directory, 'source') + await mkdir(join(source, 'python/bin'), { recursive: true }) + await writeFile(join(source, 'python/bin/python3'), 'python') + const archivePath = join(directory, 'python.tar.gz') + await create({ + cwd: source, + file: archivePath, + gzip: true, + portable: true + }, ['python']) + const destinationDirectory = join(directory, 'install') + await extractPythonArtifact({ + artifact: { archiveFormat: 'tar.gz', payloadRoot: 'python' }, + archivePath, + destinationDirectory + }) + expect(await readFile(join(destinationDirectory, 'bin/python3'), 'utf8')) + .toBe('python') + }) + + it('validates relative symlinks and rejects escaping links', async () => { + const directory = await temporaryDirectory() + if (process.platform !== 'win32') { + const safeArchive = join(directory, 'safe.tar.gz') + await writeFile(safeArchive, gzipSync(tarArchive([ + { path: 'python/bin/python3', content: 'python' }, + { + path: 'python/bin/python', + type: '2', + linkPath: 'python3' + } + ]))) + const destinationDirectory = join(directory, 'safe-install') + await extractPythonArtifact({ + artifact: { archiveFormat: 'tar.gz', payloadRoot: 'python' }, + archivePath: safeArchive, + destinationDirectory + }) + expect(await readFile(join(destinationDirectory, 'bin/python'), 'utf8')) + .toBe('python') + } + + const unsafeArchive = join(directory, 'unsafe.tar.gz') + await writeFile(unsafeArchive, gzipSync(tarArchive([ + { + path: 'python/bin/python', + type: '2', + linkPath: '../../../outside' + } + ]))) + await expect(extractPythonArtifact({ + artifact: { archiveFormat: 'tar.gz', payloadRoot: 'python' }, + archivePath: unsafeArchive, + destinationDirectory: join(directory, 'unsafe-install') + })).rejects.toThrow(/escapes payload root/u) + }) +}) diff --git a/src/main/local-tool-environment/python-artifact-extract.ts b/src/main/local-tool-environment/python-artifact-extract.ts new file mode 100644 index 00000000..b183c366 --- /dev/null +++ b/src/main/local-tool-environment/python-artifact-extract.ts @@ -0,0 +1,302 @@ +import { chmod, lstat, mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, posix, resolve, sep } from 'node:path' +import { unzipSync } from 'fflate' +import { extract, list, type ReadEntry } from 'tar' +import type { PythonArtifact } from './python-artifact-catalog' + +const MAX_ENTRIES = 20_000 +const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024 +const zipSignature = 0x02014b50 + +type SafeEntry = { + archivePath: string + outputPath: string + type: 'file' | 'directory' | 'symlink' + mode?: number + linkPath?: string + size: number +} + +function ensureNotAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw signal.reason instanceof Error + ? signal.reason + : new DOMException('The operation was aborted', 'AbortError') + } +} + +function safePayloadPath( + path: string, + root: string, + ignoreOutsideRoot = false +): string | undefined { + if ( + path.includes('\\') || + path.includes('\0') || + path.startsWith('/') || + /^[a-zA-Z]:/u.test(path) + ) { + throw new Error(`Unsafe archive path: ${path}`) + } + const normalized = posix.normalize(path) + if ( + normalized === '..' || + normalized.startsWith('../') || + normalized !== path.replace(/\/+$/u, '') + ) { + throw new Error(`Unsafe archive path: ${path}`) + } + if (normalized === root) { + return undefined + } + const prefix = `${root}/` + if (!normalized.startsWith(prefix)) { + if (ignoreOutsideRoot) { + return undefined + } + throw new Error(`Archive entry is outside payload root ${root}: ${path}`) + } + const relative = normalized.slice(prefix.length) + if (!relative) { + return undefined + } + return relative +} + +function destinationPath(root: string, relative: string): string { + const destination = resolve(root, ...relative.split('/')) + const prefix = `${resolve(root)}${sep}` + if (!destination.startsWith(prefix)) { + throw new Error(`Archive path escaped destination: ${relative}`) + } + return destination +} + +function ensureSafeSymlink(relative: string, target: string): void { + if ( + !target || + target.includes('\\') || + target.includes('\0') || + target.startsWith('/') || + /^[a-zA-Z]:/u.test(target) + ) { + throw new Error(`Unsafe archive symlink: ${relative}`) + } + const resolved = posix.normalize(posix.join(posix.dirname(relative), target)) + if (resolved === '..' || resolved.startsWith('../') || posix.isAbsolute(resolved)) { + throw new Error(`Archive symlink escapes payload root: ${relative}`) + } +} + +function verifyEntrySet(entries: SafeEntry[]): void { + if (entries.length === 0 || entries.length > MAX_ENTRIES) { + throw new Error('Archive has an invalid number of payload entries') + } + let expanded = 0 + const seen = new Set() + for (const entry of entries) { + const key = entry.outputPath.toLowerCase() + if (seen.has(key)) { + throw new Error(`Archive contains duplicate path: ${entry.outputPath}`) + } + seen.add(key) + expanded += entry.size + if (!Number.isSafeInteger(expanded) || expanded > MAX_EXPANDED_BYTES) { + throw new Error('Archive expanded size exceeds the limit') + } + } +} + +function readZipEntries(data: Buffer, root: string): SafeEntry[] { + const entries: SafeEntry[] = [] + for (let offset = 0; offset + 46 <= data.length;) { + if (data.readUInt32LE(offset) !== zipSignature) { + offset += 1 + continue + } + const madeBy = data.readUInt16LE(offset + 4) + const flags = data.readUInt16LE(offset + 8) + const size = data.readUInt32LE(offset + 24) + const nameLength = data.readUInt16LE(offset + 28) + const extraLength = data.readUInt16LE(offset + 30) + const commentLength = data.readUInt16LE(offset + 32) + const attrs = data.readUInt32LE(offset + 38) + const end = offset + 46 + nameLength + extraLength + commentLength + if (end > data.length || (flags & 1) !== 0) { + throw new Error('ZIP central directory is invalid or encrypted') + } + const archivePath = data.subarray(offset + 46, offset + 46 + nameLength) + .toString((flags & 0x800) !== 0 ? 'utf8' : 'latin1') + const relative = safePayloadPath(archivePath, root, true) + if (relative) { + const unixMode = (madeBy >>> 8) === 3 ? (attrs >>> 16) & 0xffff : undefined + const fileType = unixMode === undefined ? 0 : unixMode & 0xf000 + if (fileType !== 0 && fileType !== 0x8000 && fileType !== 0x4000) { + throw new Error(`ZIP contains a special file or link: ${archivePath}`) + } + entries.push({ + archivePath, + outputPath: relative, + type: archivePath.endsWith('/') || fileType === 0x4000 + ? 'directory' + : 'file', + mode: unixMode === undefined ? undefined : unixMode & 0o777, + size + }) + } + offset = end + } + verifyEntrySet(entries) + return entries +} + +async function extractNuget( + archivePath: string, + destination: string, + root: string, + signal?: AbortSignal +): Promise { + ensureNotAborted(signal) + const data = await readFile(archivePath) + const entries = readZipEntries(data, root) + const payloadPaths = new Set(entries.map((entry) => entry.archivePath)) + const unzipped = unzipSync(data, { + filter: ({ name }) => payloadPaths.has(name) + }) + for (const entry of entries.sort((left, right) => + left.type === 'directory' && right.type !== 'directory' ? -1 : 0 + )) { + ensureNotAborted(signal) + const output = destinationPath(destination, entry.outputPath) + if (entry.type === 'directory') { + await mkdir(output, { recursive: true, mode: entry.mode }) + continue + } + const content = unzipped[entry.archivePath] + if (!content || content.byteLength !== entry.size) { + throw new Error(`ZIP payload is missing or corrupt: ${entry.archivePath}`) + } + await mkdir(dirname(output), { recursive: true }) + await writeFile(output, content, { flag: 'wx', mode: entry.mode }) + if (entry.mode !== undefined && process.platform !== 'win32') { + await chmod(output, entry.mode) + } + } +} + +function tarEntry(entry: ReadEntry, root: string): SafeEntry | undefined { + const relative = safePayloadPath(entry.path.replace(/\/+$/u, ''), root) + if (!relative) { + return undefined + } + if (entry.type === 'SymbolicLink') { + const linkPath = entry.linkpath ?? '' + ensureSafeSymlink(relative, linkPath) + return { + archivePath: entry.path, + outputPath: relative, + type: 'symlink', + mode: entry.mode === undefined ? undefined : entry.mode & 0o777, + linkPath, + size: 0 + } + } + if (entry.type !== 'File' && entry.type !== 'Directory') { + throw new Error(`TAR contains unsupported entry type ${entry.type}: ${entry.path}`) + } + return { + archivePath: entry.path, + outputPath: relative, + type: entry.type === 'Directory' ? 'directory' : 'file', + mode: entry.mode === undefined ? undefined : entry.mode & 0o777, + size: entry.size + } +} + +async function extractTar( + archivePath: string, + destination: string, + root: string, + signal?: AbortSignal +): Promise { + const entries: SafeEntry[] = [] + let validationError: Error | undefined + await list({ + file: archivePath, + gzip: true, + strict: true, + onReadEntry: (entry) => { + try { + ensureNotAborted(signal) + const safe = tarEntry(entry, root) + if (safe) { + entries.push(safe) + } + } catch (error) { + validationError ??= error instanceof Error + ? error + : new Error('TAR validation failed') + entry.resume() + } + } + }) + if (validationError) { + throw validationError + } + verifyEntrySet(entries) + const allowed = new Map(entries.map((entry) => [entry.archivePath, entry])) + await extract({ + file: archivePath, + cwd: destination, + gzip: true, + strip: 1, + strict: true, + preservePaths: false, + noChmod: false, + filter: (path, entry) => { + ensureNotAborted(signal) + const expected = allowed.get(path) + if (!expected) { + return path.replace(/\/+$/u, '') === root + } + const checked = tarEntry(entry as ReadEntry, root) + return checked?.outputPath === expected.outputPath && + checked.type === expected.type + } + }) + for (const entry of entries) { + const output = destinationPath(destination, entry.outputPath) + const status = await lstat(output) + if ( + (entry.type === 'file' && !status.isFile()) || + (entry.type === 'directory' && !status.isDirectory()) || + (entry.type === 'symlink' && !status.isSymbolicLink()) + ) { + throw new Error(`Extracted TAR entry has the wrong type: ${entry.outputPath}`) + } + } +} + +export async function extractPythonArtifact(options: { + artifact: Pick + archivePath: string + destinationDirectory: string + signal?: AbortSignal +}): Promise { + await mkdir(options.destinationDirectory, { recursive: false }) + if (options.artifact.archiveFormat === 'nuget-zip') { + await extractNuget( + options.archivePath, + options.destinationDirectory, + options.artifact.payloadRoot, + options.signal + ) + } else { + await extractTar( + options.archivePath, + options.destinationDirectory, + options.artifact.payloadRoot, + options.signal + ) + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 03082608..5d336d45 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -78,6 +78,13 @@ import type { ModelDownloadSource, VersionCheckResult } from '../shared/application-settings-contracts' +import type { + LocalToolDiagnoseTarget, + LocalToolEnvironmentProgress, + LocalToolEnvironmentSettings, + LocalToolEnvironmentSnapshot, + LocalToolKind +} from '../shared/local-tool-environment-contracts' import type { ReleaseNotesSnapshot } from '../shared/release-notes-contracts' import type { SpeechModelSnapshot, @@ -615,6 +622,55 @@ const desktopApi: DesktopApi = { ipcRenderer.removeListener(ipcChannels.versionCheckResult, handler) } }, + localToolEnvironment: { + getSnapshot: () => + ipcRenderer.invoke( + ipcChannels.localToolEnvironmentGet + ) as Promise, + updateSettings: (settings: LocalToolEnvironmentSettings) => + ipcRenderer.invoke( + ipcChannels.localToolEnvironmentUpdate, + settings + ) as Promise, + refreshCandidates: () => + ipcRenderer.invoke( + ipcChannels.localToolEnvironmentRefresh + ) as Promise, + selectExecutable: (kind: LocalToolKind) => + ipcRenderer.invoke( + ipcChannels.localToolEnvironmentSelectExecutable, + { kind } + ) as Promise, + diagnose: (kind: LocalToolDiagnoseTarget) => + ipcRenderer.invoke( + ipcChannels.localToolEnvironmentDiagnose, + { kind } + ) as Promise, + installPython: () => + ipcRenderer.invoke( + ipcChannels.localToolEnvironmentInstallPython + ) as Promise, + cancelPython: () => + ipcRenderer.invoke( + ipcChannels.localToolEnvironmentCancelPython + ) as Promise, + removePython: () => + ipcRenderer.invoke( + ipcChannels.localToolEnvironmentRemovePython + ) as Promise, + onProgress: (listener) => { + const handler = ( + _event: Electron.IpcRendererEvent, + progress: LocalToolEnvironmentProgress + ): void => listener(progress) + ipcRenderer.on(ipcChannels.localToolEnvironmentProgress, handler) + return () => + ipcRenderer.removeListener( + ipcChannels.localToolEnvironmentProgress, + handler + ) + } + }, feedback: { submit: (input: FeedbackSubmitInput) => ipcRenderer.invoke( diff --git a/src/preload/preload-sandbox.test.ts b/src/preload/preload-sandbox.test.ts index e2a33192..b20bcec8 100644 --- a/src/preload/preload-sandbox.test.ts +++ b/src/preload/preload-sandbox.test.ts @@ -46,6 +46,34 @@ describe('sandboxed preload', () => { ) }) + it('exposes bounded local tool environment operations without execution inputs', () => { + const source = readFileSync( + join(process.cwd(), 'src', 'preload', 'index.ts'), + 'utf8' + ) + const localTools = + source.match( + /localToolEnvironment: \{(?[\s\S]*?)\r?\n {2}\},\r?\n {2}feedback:/u + )?.groups?.body ?? '' + for (const method of [ + 'getSnapshot:', + 'updateSettings:', + 'refreshCandidates:', + 'selectExecutable:', + 'diagnose:', + 'installPython:', + 'cancelPython:', + 'removePython:', + 'onProgress:' + ]) { + expect(localTools).toContain(method) + } + expect(localTools).not.toMatch( + /\b(?:command|env|url|hash|installDirectory|shim)\b/iu + ) + expect(localTools).toContain('ipcRenderer.removeListener(') + }) + it('exposes only explicit computer capability and managed profile IPC methods', () => { const source = readFileSync( join(process.cwd(), 'src', 'preload', 'index.ts'), diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index ab5d96ef..8f8844e8 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -16,7 +16,10 @@ import type { ConversationQueueDispatch, DesktopApi } from '../../shared/contracts' -import type { ApplicationSettings } from '../../shared/application-settings-contracts' +import { + defaultLocalToolEnvironmentSettings, + type ApplicationSettings +} from '../../shared/application-settings-contracts' import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut' import type { TerminalSnapshot @@ -989,6 +992,7 @@ function installRemoteProjectsSetting(enabled: boolean): { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: enabled, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, @@ -2148,6 +2152,7 @@ describe('App', () => { checkUpdatesOnStartup: false, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, @@ -2243,6 +2248,7 @@ describe('App', () => { checkUpdatesOnStartup: true, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, @@ -2253,6 +2259,7 @@ describe('App', () => { checkUpdatesOnStartup: true, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, @@ -2353,6 +2360,7 @@ describe('App', () => { checkUpdatesOnStartup: true, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, @@ -2363,6 +2371,7 @@ describe('App', () => { checkUpdatesOnStartup: true, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, @@ -4316,6 +4325,7 @@ describe('App', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, @@ -10523,6 +10533,7 @@ describe('App', () => { checkUpdatesOnStartup: false, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, @@ -10533,6 +10544,7 @@ describe('App', () => { checkUpdatesOnStartup: false, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, @@ -10579,6 +10591,7 @@ describe('App', () => { checkUpdatesOnStartup: false, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, @@ -10632,6 +10645,7 @@ describe('App', () => { checkUpdatesOnStartup: false, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: false, @@ -10664,6 +10678,7 @@ describe('App', () => { checkUpdatesOnStartup: false, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, @@ -10674,6 +10689,7 @@ describe('App', () => { checkUpdatesOnStartup: false, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, @@ -10709,6 +10725,7 @@ describe('App', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, @@ -10730,7 +10747,12 @@ describe('App', () => { } try { const { container } = render() - fireEvent.click(await screen.findByText('本地工作区')) + fireEvent.click( + await screen.findByRole('button', { + name: /本地工作区/u + }) + ) + await screen.findByRole('heading', { name: '设置中心' }) fireEvent.click( screen.getByRole('tab', { name: '平台功能' }) ) diff --git a/src/renderer/src/CapabilitiesAndToolsSettingsSection.tsx b/src/renderer/src/CapabilitiesAndToolsSettingsSection.tsx index 6c693a88..d2296ad6 100644 --- a/src/renderer/src/CapabilitiesAndToolsSettingsSection.tsx +++ b/src/renderer/src/CapabilitiesAndToolsSettingsSection.tsx @@ -3,19 +3,24 @@ import { useTranslation } from 'react-i18next' import { McpSettingsSection } from './McpSettingsSection' import { SettingsCategoryHeader } from './SettingsPrimitives' import { SkillsSettingsSection } from './SkillsSettingsSection' +import { ToolEnvironmentSettingsSection } from './ToolEnvironmentSettingsSection' import { PageTabs } from './WorkspacePrimitives' +import type { AppNotificationInput } from './notifications' -type CapabilitySettingsTab = 'skills' | 'mcp' +type CapabilitySettingsTab = 'skills' | 'mcp' | 'tools' export function CapabilitiesAndToolsSettingsSection({ - magicNotesEnabled + magicNotesEnabled, + onNotify }: { magicNotesEnabled: boolean + onNotify?: (notification: AppNotificationInput) => void }): React.JSX.Element { const { t } = useTranslation('settings') const [activeTab, setActiveTab] = useState('skills') const [mcpVisited, setMcpVisited] = useState(false) + const [toolsVisited, setToolsVisited] = useState(false) return ( <> @@ -32,6 +37,9 @@ export function CapabilitiesAndToolsSettingsSection({ if (tab === 'mcp') { setMcpVisited(true) } + if (tab === 'tools') { + setToolsVisited(true) + } }} tabs={[ { @@ -41,6 +49,10 @@ export function CapabilitiesAndToolsSettingsSection({ { id: 'mcp', label: t('capabilities.tabs.mcp') + }, + { + id: 'tools', + label: t('capabilities.tabs.tools') } ]} value={activeTab} @@ -67,6 +79,17 @@ export function CapabilitiesAndToolsSettingsSection({ )} + ) } diff --git a/src/renderer/src/MagicNotesWorkspace.test.tsx b/src/renderer/src/MagicNotesWorkspace.test.tsx index 83fc7169..8786d132 100644 --- a/src/renderer/src/MagicNotesWorkspace.test.tsx +++ b/src/renderer/src/MagicNotesWorkspace.test.tsx @@ -10,7 +10,10 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import i18n from './i18n' import type { DesktopApi } from '../../shared/contracts' -import type { ApplicationSettings } from '../../shared/application-settings-contracts' +import { + defaultLocalToolEnvironmentSettings, + type ApplicationSettings +} from '../../shared/application-settings-contracts' import type { MagicNoteDetail, MagicNotesSnapshot, @@ -189,6 +192,7 @@ const getApplicationSettings = vi.fn<() => Promise>(async ( checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, @@ -204,6 +208,7 @@ beforeEach(() => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, @@ -1227,6 +1232,7 @@ describe('MagicNotesWorkspace', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, @@ -1261,6 +1267,7 @@ describe('MagicNotesWorkspace', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, @@ -1378,6 +1385,7 @@ describe('MagicNotesWorkspace', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, diff --git a/src/renderer/src/SettingsPanel.test.tsx b/src/renderer/src/SettingsPanel.test.tsx index 86f973fb..9c8b449d 100644 --- a/src/renderer/src/SettingsPanel.test.tsx +++ b/src/renderer/src/SettingsPanel.test.tsx @@ -23,7 +23,10 @@ import type { CapabilityAssignments, CapabilitySnapshot } from '../../shared/capability-contracts' -import type { ApplicationSettings } from '../../shared/application-settings-contracts' +import { + defaultLocalToolEnvironmentSettings, + type ApplicationSettings +} from '../../shared/application-settings-contracts' import type { AgentPackageInventory } from '../../shared/agent-package-contracts' @@ -53,7 +56,9 @@ import type { BrandingPreferences } from './branding' const modelProfileId = '00000000-0000-4000-8000-000000000001' const nativeImage = globalThis.Image -function openCapabilitySettingsTab(tab: 'Skills' | 'MCP'): void { +function openCapabilitySettingsTab( + tab: 'Skills' | 'MCP' | '工具执行环境' +): void { fireEvent.click( screen.getByRole('tab', { name: '能力与工具' }) ) @@ -530,6 +535,7 @@ let applicationSettings: ApplicationSettings = { checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, @@ -585,6 +591,35 @@ const updateApplicationSettings = vi.fn< } return { ...applicationSettings } }) +const localToolEnvironmentSnapshot = { + settings: defaultLocalToolEnvironmentSettings, + candidates: [ + { + kind: 'node' as const, + executablePath: 'C:\\Tools\\node.exe', + version: '22.14.0', + architecture: 'x64' + } + ], + diagnostics: {}, + managedPython: { + version: '3.12.8', + installed: false + } +} +const getLocalToolEnvironmentSnapshot = vi.fn( + async () => localToolEnvironmentSnapshot +) +const updateLocalToolEnvironmentSettings = vi.fn< + NonNullable['updateSettings'] +>(async (settings) => ({ + ...localToolEnvironmentSnapshot, + settings +})) +const diagnoseLocalToolEnvironment = vi.fn( + async () => localToolEnvironmentSnapshot +) +const onLocalToolEnvironmentProgress = vi.fn(() => () => {}) let shortcutSettingsSnapshot: GlobalShortcutSettingsSnapshot = { settings: { enabled: true, @@ -790,6 +825,7 @@ describe('SettingsPanel runtime files', () => { checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, @@ -951,6 +987,25 @@ describe('SettingsPanel runtime files', () => { openReleasePage: vi.fn(), onResult: vi.fn(() => () => {}) }, + localToolEnvironment: { + getSnapshot: getLocalToolEnvironmentSnapshot, + updateSettings: updateLocalToolEnvironmentSettings, + refreshCandidates: vi.fn( + async () => localToolEnvironmentSnapshot + ), + selectExecutable: vi.fn( + async () => localToolEnvironmentSnapshot + ), + diagnose: diagnoseLocalToolEnvironment, + installPython: vi.fn( + async () => localToolEnvironmentSnapshot + ), + cancelPython: vi.fn(async () => true), + removePython: vi.fn( + async () => localToolEnvironmentSnapshot + ), + onProgress: onLocalToolEnvironmentProgress + }, shortcuts: { getSettings: getShortcutSettings, updateSettings: updateShortcutSettings @@ -4844,7 +4899,7 @@ describe('SettingsPanel runtime files', () => { within(capabilityTabs) .getAllByRole('tab') .map((tab) => tab.textContent) - ).toEqual(['Skills', 'MCP']) + ).toEqual(['Skills', 'MCP', '工具执行环境']) expect( within(capabilityTabs).getByRole('tab', { name: 'Skills' }) ).toHaveAttribute('aria-selected', 'true') @@ -4882,12 +4937,6 @@ describe('SettingsPanel runtime files', () => { ) ) - expect( - within(capabilityTabs).queryByRole('tab', { - name: '工具执行环境' - }) - ).not.toBeInTheDocument() - fireEvent.click( within(capabilityTabs).getByRole('tab', { name: 'MCP' }) ) @@ -5202,6 +5251,58 @@ describe('SettingsPanel runtime files', () => { ).not.toBeInTheDocument() }) + it('lazy mounts and retains the real tool environment tab independently from MCP', async () => { + render( + {})} + onClose={vi.fn()} + onSaved={vi.fn()} + /> + ) + + fireEvent.click( + screen.getByRole('tab', { name: '能力与工具' }) + ) + expect(getLocalToolEnvironmentSnapshot).not.toHaveBeenCalled() + + fireEvent.click( + within( + screen.getByRole('tablist', { name: '能力与工具设置' }) + ).getByRole('tab', { name: '工具执行环境' }) + ) + expect( + await screen.findByRole('heading', { name: 'Node.js' }) + ).toBeVisible() + expect(getLocalToolEnvironmentSnapshot).toHaveBeenCalledOnce() + + fireEvent.click( + screen.getByRole('button', { name: '诊断全部' }) + ) + await waitFor(() => + expect(diagnoseLocalToolEnvironment).toHaveBeenCalledWith('all') + ) + + fireEvent.click( + within( + screen.getByRole('tablist', { name: '能力与工具设置' }) + ).getByRole('tab', { name: 'MCP' }) + ) + expect( + await screen.findByRole('tablist', { name: 'MCP 设置分类' }) + ).toBeVisible() + fireEvent.click( + within( + screen.getByRole('tablist', { name: '能力与工具设置' }) + ).getByRole('tab', { name: '工具执行环境' }) + ) + expect( + screen.getByRole('heading', { name: 'Node.js' }) + ).toBeVisible() + expect(getLocalToolEnvironmentSnapshot).toHaveBeenCalledOnce() + }) + it('traps MCP editor focus and restores it after saving or backdrop dismissal', async () => { render( )} {activeTab === 'about' && ( diff --git a/src/renderer/src/ToolEnvironmentSettingsSection.test.tsx b/src/renderer/src/ToolEnvironmentSettingsSection.test.tsx new file mode 100644 index 00000000..43fec16a --- /dev/null +++ b/src/renderer/src/ToolEnvironmentSettingsSection.test.tsx @@ -0,0 +1,338 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within +} from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DesktopApi } from '../../shared/contracts' +import type { + LocalToolEnvironmentProgress, + LocalToolEnvironmentSnapshot +} from '../../shared/local-tool-environment-contracts' +import { changeUiLocale } from './i18n' +import { ToolEnvironmentSettingsSection } from './ToolEnvironmentSettingsSection' + +const baseSnapshot: LocalToolEnvironmentSnapshot = { + settings: { + artifactDownloadSource: 'native', + node: { + source: 'custom', + executablePath: 'C:\\Broken\\node.exe' + }, + python: { source: 'managed' } + }, + candidates: [ + { + kind: 'node', + executablePath: 'C:\\Tools\\node.exe', + version: '22.14.0', + architecture: 'x64' + }, + { + kind: 'python', + executablePath: 'C:\\Python\\python.exe', + version: '3.12.8', + architecture: 'amd64' + } + ], + diagnostics: { + node: { + available: false, + source: 'custom', + executablePath: 'C:\\Broken\\node.exe', + detail: 'The selected executable could not be started.' + }, + npm: { + available: false, + source: 'custom', + detail: 'npm is unavailable.' + }, + npx: { + available: false, + source: 'custom', + detail: 'npx is unavailable.' + }, + python: { + available: true, + source: 'managed', + version: 'Python 3.12.8', + executablePath: 'C:\\GoodBuddy\\python.exe', + detail: 'Ready' + }, + pip: { + available: true, + source: 'managed', + version: 'pip 24.3', + executablePath: 'C:\\GoodBuddy\\pip.exe', + detail: 'Ready' + } + }, + managedPython: { + version: '3.12.8', + installed: true, + executablePath: 'C:\\GoodBuddy\\python.exe' + } +} + +describe('ToolEnvironmentSettingsSection', () => { + let progressListener: + | ((progress: LocalToolEnvironmentProgress) => void) + | undefined + const unsubscribe = vi.fn() + const getSnapshot = vi.fn(async () => baseSnapshot) + const updateSettings = vi.fn< + NonNullable['updateSettings'] + >(async (settings) => ({ ...baseSnapshot, settings })) + const refreshCandidates = vi.fn(async () => baseSnapshot) + const selectExecutable = vi.fn(async () => baseSnapshot) + const diagnose = vi.fn(async () => baseSnapshot) + const installPython = vi.fn(async () => baseSnapshot) + const cancelPython = vi.fn(async () => true) + const removePython = vi.fn(async () => ({ + ...baseSnapshot, + managedPython: { + version: '3.12.8', + installed: false + } + })) + + beforeEach(async () => { + vi.clearAllMocks() + await changeUiLocale('zh-CN') + progressListener = undefined + Object.defineProperty(window, 'goodbuddy', { + configurable: true, + value: { + localToolEnvironment: { + getSnapshot, + updateSettings, + refreshCandidates, + selectExecutable, + diagnose, + installPython, + cancelPython, + removePython, + onProgress: ( + listener: ( + progress: LocalToolEnvironmentProgress + ) => void + ) => { + progressListener = listener + return unsubscribe + } + } + } as unknown as DesktopApi + }) + }) + + afterEach(cleanup) + + it('loads and unsubscribes while preserving an invalid selected path', async () => { + const view = render() + + expect( + (await screen.findAllByText('C:\\Broken\\node.exe'))[0] + ).toBeVisible() + expect(screen.getAllByText('工具状态')).toHaveLength(2) + expect(screen.getAllByText('工具链/配套工具状态')).toHaveLength(2) + expect(screen.getAllByText('能力依赖状态')).toHaveLength(2) + expect( + screen.getByRole('button', { + name: /C:\\Tools\\node\.exe/u + }) + ).toHaveTextContent('22.14.0 · x64') + expect( + screen.getByText( + /仅用于本机项目的新建本地 Runtime 进程和 stdio MCP Server/u + ) + ).toBeVisible() + + view.unmount() + expect(unsubscribe).toHaveBeenCalledOnce() + }) + + it('saves the full nested settings and rolls back a failed source change', async () => { + const onNotify = vi.fn() + updateSettings.mockRejectedValueOnce(new Error('source offline')) + render( + + ) + + const sourceGroup = await screen.findByRole('group', { + name: '选择工具制品下载源' + }) + const native = within(sourceGroup).getByRole('radio', { + name: /原生地址/u + }) + const oss = within(sourceGroup).getByRole('radio', { + name: /OSS 镜像/u + }) + expect(native).toBeChecked() + fireEvent.click(oss) + + await waitFor(() => + expect(updateSettings).toHaveBeenCalledWith({ + artifactDownloadSource: 'oss', + node: { + source: 'custom', + executablePath: 'C:\\Broken\\node.exe' + }, + python: { source: 'managed' } + }) + ) + await waitFor(() => expect(native).toBeChecked()) + expect(screen.getByRole('alert')).toHaveTextContent('source offline') + expect(onNotify).not.toHaveBeenCalled() + }) + + it('uses real diagnose, file selection, managed Python, and progress APIs', async () => { + const onNotify = vi.fn() + render( + + ) + + await screen.findByText('工具下载源') + fireEvent.click( + screen.getByRole('button', { name: '诊断全部' }) + ) + await waitFor(() => expect(diagnose).toHaveBeenCalledWith('all')) + + const nodeCard = screen + .getByRole('heading', { name: 'Node.js' }) + .closest('article')! + fireEvent.click( + within(nodeCard).getByRole('button', { + name: '选择可执行文件' + }) + ) + await waitFor(() => + expect(selectExecutable).toHaveBeenCalledWith('node') + ) + + const pythonCard = ( + await screen.findByRole('heading', { name: 'Python' }) + ).closest('article')! + fireEvent.click( + within(pythonCard).getByRole('button', { + name: '更新 Python' + }) + ) + await waitFor(() => expect(installPython).toHaveBeenCalledOnce()) + + act(() => { + progressListener?.({ + snapshot: { + ...baseSnapshot, + managedPython: { + ...baseSnapshot.managedPython, + operation: { + source: 'native', + phase: 'downloading', + receivedBytes: 50, + totalBytes: 100 + } + } + } + }) + }) + const progress = await screen.findByRole('progressbar', { + name: 'Python 安装或更新进度' + }) + expect(progress).toHaveAttribute('aria-valuenow', '50') + expect(screen.getByText('下载源:原生地址')).toBeVisible() + fireEvent.click( + within(pythonCard).getByRole('button', { name: '取消' }) + ) + await waitFor(() => expect(cancelPython).toHaveBeenCalledOnce()) + + act(() => { + progressListener?.({ snapshot: baseSnapshot }) + }) + await waitFor(() => + expect( + screen.queryByRole('progressbar', { + name: 'Python 安装或更新进度' + }) + ).not.toBeInTheDocument() + ) + const updatedPythonCard = screen + .getByRole('heading', { name: 'Python' }) + .closest('article')! + fireEvent.click( + within(updatedPythonCard).getByRole('button', { + name: '移除 Python' + }) + ) + expect( + within(updatedPythonCard).getByText( + /本机 Runtime 和 stdio MCP 将无法使用/u + ) + ).toBeVisible() + fireEvent.click( + within(updatedPythonCard).getByRole('button', { + name: '确认移除 Python' + }) + ) + await waitFor(() => expect(removePython).toHaveBeenCalledOnce()) + }) + + it('does not report a cancelled Python install as a failure', async () => { + const onNotify = vi.fn() + let rejectInstall!: (reason: unknown) => void + installPython.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectInstall = reject + }) + ) + render() + + const pythonCard = ( + await screen.findByRole('heading', { name: 'Python' }) + ).closest('article')! + fireEvent.click( + within(pythonCard).getByRole('button', { name: '更新 Python' }) + ) + await waitFor(() => expect(installPython).toHaveBeenCalledOnce()) + act(() => { + progressListener?.({ + snapshot: { + ...baseSnapshot, + managedPython: { + ...baseSnapshot.managedPython, + operation: { + source: 'oss', + phase: 'validating' + } + } + } + }) + }) + expect(await screen.findByText('下载源:OSS 镜像')).toBeVisible() + + fireEvent.click( + within(pythonCard).getByRole('button', { name: '取消' }) + ) + await waitFor(() => expect(cancelPython).toHaveBeenCalledOnce()) + act(() => { + progressListener?.({ snapshot: baseSnapshot }) + rejectInstall( + new Error( + "Error invoking remote method 'local-tool-environment:install-python': AbortError: Managed Python operation cancelled" + ) + ) + }) + + await waitFor(() => + expect( + screen.queryByText('安装或更新 Python 失败') + ).not.toBeInTheDocument() + ) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(onNotify).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/ToolEnvironmentSettingsSection.tsx b/src/renderer/src/ToolEnvironmentSettingsSection.tsx new file mode 100644 index 00000000..f56c86ad --- /dev/null +++ b/src/renderer/src/ToolEnvironmentSettingsSection.tsx @@ -0,0 +1,711 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { + LocalToolEnvironmentSettings, + LocalToolEnvironmentSnapshot, + LocalToolKind, + LocalToolRuntimeSelection +} from '../../shared/local-tool-environment-contracts' +import type { AppNotificationInput } from './notifications' +import { displayErrorMessage } from './error-message' + +type ToolEnvironmentSettingsSectionProps = { + onNotify?: (notification: AppNotificationInput) => void +} + +function formatBytes(value: number): string { + if (value < 1_024) { + return `${value} B` + } + if (value < 1_024 * 1_024) { + return `${(value / 1_024).toFixed(1)} KiB` + } + return `${(value / (1_024 * 1_024)).toFixed(1)} MiB` +} + +function isManagedPythonCancellation(reason: unknown): boolean { + if ( + reason instanceof DOMException && + reason.name === 'AbortError' + ) { + return true + } + if (typeof reason !== 'object' || reason === null) { + return false + } + const error = reason as { name?: unknown; message?: unknown } + return ( + error.name === 'AbortError' || + (typeof error.message === 'string' && + error.message.includes('Managed Python operation cancelled')) + ) +} + +export function ToolEnvironmentSettingsSection({ + onNotify = () => {} +}: ToolEnvironmentSettingsSectionProps): React.JSX.Element { + const { t } = useTranslation('settingsSections') + const [snapshot, setSnapshot] = + useState() + const [loadError, setLoadError] = useState() + const [loadAttempt, setLoadAttempt] = useState(0) + const [actionError, setActionError] = useState() + const [busy, setBusy] = useState() + const [confirmingRemove, setConfirmingRemove] = useState(false) + const pythonCancellationRequested = useRef(false) + const toolApi = window.goodbuddy.localToolEnvironment + + const errorMessage = useCallback( + (reason: unknown, fallback: string): string => + displayErrorMessage(reason, fallback), + [] + ) + + useEffect(() => { + const api = toolApi + if (!api) { + return + } + let active = true + const unsubscribe = api.onProgress(({ snapshot: next }) => { + if (active) { + setSnapshot(next) + } + }) + void api.getSnapshot().then( + (next) => { + if (active) { + setSnapshot(next) + setLoadError(undefined) + } + }, + (reason: unknown) => { + if (active) { + setLoadError( + errorMessage( + reason, + t('toolEnvironment.errors.readFailed') + ) + ) + } + } + ) + return () => { + active = false + unsubscribe() + } + }, [errorMessage, loadAttempt, t, toolApi]) + + const updateSettings = async ( + nextSettings: LocalToolEnvironmentSettings, + successMessage: string + ): Promise => { + const api = window.goodbuddy.localToolEnvironment + if (!api || !snapshot) { + return + } + const confirmedSnapshot = snapshot + setSnapshot({ ...snapshot, settings: nextSettings }) + setActionError(undefined) + setBusy('settings') + try { + const next = await api.updateSettings(nextSettings) + setSnapshot(next) + onNotify({ + tone: 'success', + message: successMessage, + dedupeKey: 'local-tool-environment-settings' + }) + } catch (reason) { + setSnapshot(confirmedSnapshot) + setActionError( + errorMessage( + reason, + t('toolEnvironment.errors.saveFailed') + ) + ) + } finally { + setBusy(undefined) + } + } + + const updateRuntime = async ( + kind: LocalToolKind, + selection: LocalToolRuntimeSelection + ): Promise => { + if (!snapshot) { + return + } + await updateSettings( + { ...snapshot.settings, [kind]: selection }, + t('toolEnvironment.notifications.runtimeChanged', { + runtime: t(`toolEnvironment.runtimes.${kind}.title`) + }) + ) + } + + const runSnapshotAction = async ( + key: string, + action: () => Promise, + fallback: string, + successMessage?: string + ): Promise => { + setBusy(key) + setActionError(undefined) + if (key === 'install-python') { + pythonCancellationRequested.current = false + } + try { + const next = await action() + setSnapshot(next) + if ( + successMessage && + !(key === 'install-python' && pythonCancellationRequested.current) + ) { + onNotify({ + tone: 'success', + message: successMessage, + dedupeKey: `local-tool-environment-${key}` + }) + } + return true + } catch (reason) { + if ( + key === 'install-python' && + isManagedPythonCancellation(reason) + ) { + return false + } + setActionError(errorMessage(reason, fallback)) + return false + } finally { + setBusy(undefined) + } + } + + if ((!toolApi || loadError) && !snapshot) { + return ( +
+

+ {loadError ?? t('toolEnvironment.errors.unavailable')} +

+ {toolApi && ( + + )} +
+ ) + } + + if (!snapshot) { + return ( +

+ {t('toolEnvironment.loading')} +

+ ) + } + + const api = toolApi! + const operation = snapshot.managedPython.operation + const progress = + operation?.receivedBytes !== undefined && + operation.totalBytes !== undefined + ? Math.min( + 100, + Math.round( + (operation.receivedBytes / operation.totalBytes) * 100 + ) + ) + : undefined + + const renderRuntimeCard = (kind: LocalToolKind): React.JSX.Element => { + const selection = snapshot.settings[kind] + const primaryDiagnostic = + kind === 'node' + ? snapshot.diagnostics.node + : snapshot.diagnostics.python ?? + snapshot.diagnostics.python3 + const dependencyDiagnostics = + kind === 'node' + ? [ + ['npm', snapshot.diagnostics.npm], + ['npx', snapshot.diagnostics.npx] + ] as const + : [['pip', snapshot.diagnostics.pip]] as const + const candidates = snapshot.candidates.filter( + (candidate) => candidate.kind === kind + ) + const selectedCustomPath = + selection.source === 'custom' + ? selection.executablePath + : undefined + const customPath = selectedCustomPath ?? candidates[0]?.executablePath + + return ( +
+
+
+

{t(`toolEnvironment.runtimes.${kind}.title`)}

+

{t(`toolEnvironment.runtimes.${kind}.description`)}

+
+ +
+ +
+ + {t('toolEnvironment.runtimeSourceLegend', { + runtime: t(`toolEnvironment.runtimes.${kind}.title`) + })} + + + +
+ +
+
+ + +
+ {selectedCustomPath && ( +

+ {t('toolEnvironment.selectedPath')} + {selectedCustomPath} +

+ )} + {candidates.length > 0 ? ( +
    + {candidates.map((candidate) => { + return ( +
  • + +
  • + ) + })} +
+ ) : ( +

+ {t('toolEnvironment.noCandidates')} +

+ )} +
+ +
+

{t('toolEnvironment.toolStatus')}

+ {primaryDiagnostic ? ( +
+
{t('toolEnvironment.status')}
+
+ {primaryDiagnostic.available + ? t('toolEnvironment.available') + : t('toolEnvironment.unavailable')} +
+ {primaryDiagnostic.version && ( + <> +
{t('toolEnvironment.version')}
+
{primaryDiagnostic.version}
+ + )} + {primaryDiagnostic.executablePath && ( + <> +
{t('toolEnvironment.path')}
+
+ {primaryDiagnostic.executablePath} +
+ + )} +
{t('toolEnvironment.detail')}
+
{primaryDiagnostic.detail}
+
+ ) : ( +

{t('toolEnvironment.notDiagnosed')}

+ )} +
+ +
+

{t('toolEnvironment.companionStatus')}

+ {dependencyDiagnostics.some(([, value]) => value) ? ( +
    + {dependencyDiagnostics.map( + ([name, diagnostic]) => + diagnostic && ( +
  • + {name} + + {diagnostic.available + ? t('toolEnvironment.available') + : t('toolEnvironment.unavailable')} + {diagnostic.version + ? ` · ${diagnostic.version}` + : ''} + + {diagnostic.detail} +
  • + ) + )} +
+ ) : ( +

{t('toolEnvironment.notDiagnosed')}

+ )} +
+ +
+

{t('toolEnvironment.capabilityDependencyStatus')}

+

{t('toolEnvironment.capabilityDependenciesUnverified')}

+
+ + {kind === 'python' && selection.source === 'managed' && ( +
+

+ {snapshot.managedPython.installed + ? t('toolEnvironment.python.installed', { + version: snapshot.managedPython.version + }) + : t('toolEnvironment.python.notInstalled', { + version: snapshot.managedPython.version + })} +

+ {snapshot.managedPython.executablePath && ( + {snapshot.managedPython.executablePath} + )} + {operation && ( +
+
+ {progress !== undefined && ( + + )} +
+ + {t( + `toolEnvironment.python.phases.${operation.phase}` + )} + {operation.receivedBytes !== undefined && + ` · ${formatBytes(operation.receivedBytes)}`} + {operation.totalBytes !== undefined && + ` / ${formatBytes(operation.totalBytes)}`} + + + {t('toolEnvironment.python.progressSource', { + source: t( + `toolEnvironment.downloadSource.options.${operation.source}.label` + ) + })} + +
+ )} +
+ {operation ? ( + + ) : ( + + )} + {snapshot.managedPython.installed && + !operation && + (confirmingRemove ? ( + <> + + {t('toolEnvironment.python.removeConfirmation')} + + + + + ) : ( + + ))} +
+
+ )} +
+ ) + } + + return ( +
+

+ {t('toolEnvironment.scope')} +

+ {actionError && ( +

+ {actionError} +

+ )} +
+
+
+

{t('toolEnvironment.downloadSource.title')}

+

{t('toolEnvironment.downloadSource.description')}

+
+ +
+
+ {t('toolEnvironment.downloadSource.legend')} + {(['native', 'oss'] as const).map((source) => ( + + ))} +
+

+ {t('toolEnvironment.downloadSource.noFallback')} +

+
+ {renderRuntimeCard('node')} + {renderRuntimeCard('python')} +
+ ) +} diff --git a/src/renderer/src/UpdateSettingsSection.test.tsx b/src/renderer/src/UpdateSettingsSection.test.tsx index 270c7ef5..45d0fd01 100644 --- a/src/renderer/src/UpdateSettingsSection.test.tsx +++ b/src/renderer/src/UpdateSettingsSection.test.tsx @@ -6,7 +6,10 @@ import { waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { ApplicationSettings } from '../../shared/application-settings-contracts' +import { + defaultLocalToolEnvironmentSettings, + type ApplicationSettings +} from '../../shared/application-settings-contracts' import type { DesktopApi } from '../../shared/contracts' import { UpdateSettingsSection } from './UpdateSettingsSection' @@ -21,6 +24,7 @@ describe('UpdateSettingsSection', () => { checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, diff --git a/src/renderer/src/i18n/locales/en-US/settings.ts b/src/renderer/src/i18n/locales/en-US/settings.ts index 684d44c8..e547e1d6 100644 --- a/src/renderer/src/i18n/locales/en-US/settings.ts +++ b/src/renderer/src/i18n/locales/en-US/settings.ts @@ -75,8 +75,8 @@ export const settings = { }, capabilities: { label: 'Capabilities and tools', - navigationDescription: 'Skills and MCP', - description: 'Manage Skills and MCP' + navigationDescription: 'Skills, MCP, and local tool environment', + description: 'Manage Skills, MCP, and the local tool environment' }, about: { label: 'About and updates', @@ -89,7 +89,8 @@ export const settings = { tabs: { ariaLabel: 'Capabilities and tools settings', skills: 'Skills', - mcp: 'MCP' + mcp: 'MCP', + tools: 'Tool execution environment' } }, actions: { diff --git a/src/renderer/src/i18n/locales/en-US/settingsSections.ts b/src/renderer/src/i18n/locales/en-US/settingsSections.ts index c54012a2..8be5ab0e 100644 --- a/src/renderer/src/i18n/locales/en-US/settingsSections.ts +++ b/src/renderer/src/i18n/locales/en-US/settingsSections.ts @@ -731,6 +731,116 @@ export const settingsSections = { assignedTo: 'Assigned to', deleteAria: 'Delete {{name}}' }, + toolEnvironment: { + loading: 'Loading the local tool environment…', + scope: + 'Applies only to newly started local Runtime processes and stdio MCP servers for local projects. It does not change terminals or remote Hosts.', + runtimeSourceLegend: '{{runtime}} execution source', + sources: { + managed: 'GoodBuddy managed', + custom: 'Custom environment', + customDescription: 'Use a local executable that you select.' + }, + runtimes: { + node: { + title: 'Node.js', + description: + 'Used by local tools that require Node.js, npm, or npx.', + managedDescription: + 'Use the Node.js managed with GoodBuddy. There are no separate install or remove actions.' + }, + python: { + title: 'Python', + description: 'Used by local tools that require Python or pip.', + managedDescription: + 'Use GoodBuddy-managed Python, which can be installed, updated, or removed as needed.' + } + }, + downloadSource: { + title: 'Tool download source', + description: + 'Controls only downloads for GoodBuddy-managed local tool artifacts. Models, app updates, terminals, and remote Hosts are unaffected.', + legend: 'Choose the tool artifact download source', + noFallback: + 'GoodBuddy accesses only the selected source and does not automatically fall back to the other source.', + options: { + native: { + label: 'Native endpoint', + description: + 'Use the default GoodBuddy tool artifact source.' + }, + oss: { + label: 'OSS mirror', + description: + 'Use the OSS tool artifact source for intranet or mirror networks.' + } + } + }, + selectedPath: 'Current custom path', + candidateList: '{{runtime}} executable candidates', + noCandidates: + 'No candidates were discovered. Refresh candidates or choose an executable with the system file picker.', + toolStatus: 'Tool status', + companionStatus: 'Toolchain/companion status', + capabilityDependencyStatus: 'Capability dependency status', + capabilityDependenciesUnverified: + 'Capability dependencies are unverified because no capability evidence is available.', + status: 'Status', + available: 'Available', + unavailable: 'Unavailable', + version: 'Version', + path: 'Path', + detail: 'Details', + notDiagnosed: 'Not diagnosed', + python: { + installed: 'GoodBuddy-managed Python {{version}} is installed', + notInstalled: + 'GoodBuddy-managed Python {{version}} is not installed', + progressLabel: 'Python installation or update progress', + progressSource: 'Download source: {{source}}', + removeConfirmation: + 'After removal, local Runtimes and stdio MCP servers cannot use this managed Python until it is installed again. Custom Python is unaffected.', + phases: { + downloading: 'Downloading', + extracting: 'Extracting', + validating: 'Validating', + publishing: 'Installing' + } + }, + actions: { + retry: 'Retry', + diagnose: 'Diagnose', + diagnosing: 'Diagnosing…', + diagnoseAll: 'Diagnose all', + refreshCandidates: 'Refresh candidates', + chooseFile: 'Choose executable', + installPython: 'Install Python', + updatePython: 'Update Python', + cancel: 'Cancel', + removePython: 'Remove Python', + confirmRemovePython: 'Confirm removal of Python' + }, + errors: { + unavailable: + 'The local tool environment service is unavailable in this version', + readFailed: 'Could not load the local tool environment', + saveFailed: + 'Could not save the tool environment settings. The last confirmed settings were restored.', + refreshFailed: 'Could not refresh executable candidates', + selectFailed: 'Could not select the {{runtime}} executable', + diagnoseFailed: 'Could not diagnose {{runtime}}', + diagnoseAllFailed: 'Could not diagnose the local tool environment', + installPythonFailed: 'Could not install or update Python', + cancelPythonFailed: 'Could not cancel the Python operation', + removePythonFailed: 'Could not remove Python' + }, + notifications: { + runtimeChanged: '{{runtime}} execution source updated', + downloadSourceChanged: 'Tool download source updated', + pythonInstalled: 'GoodBuddy-managed Python installed or updated', + pythonRemoved: 'GoodBuddy-managed Python removed' + } + }, feedback: { entry: { title: 'Help improve GoodBuddy', diff --git a/src/renderer/src/i18n/locales/zh-CN/settings.ts b/src/renderer/src/i18n/locales/zh-CN/settings.ts index ab5330b4..d9c896ee 100644 --- a/src/renderer/src/i18n/locales/zh-CN/settings.ts +++ b/src/renderer/src/i18n/locales/zh-CN/settings.ts @@ -64,8 +64,8 @@ export const settings = { }, capabilities: { label: '能力与工具', - navigationDescription: 'Skills 与 MCP', - description: '管理 Skills 和 MCP' + navigationDescription: 'Skills、MCP 与本机工具环境', + description: '管理 Skills、MCP 和本机工具执行环境' }, about: { label: '关于与更新', @@ -77,7 +77,8 @@ export const settings = { tabs: { ariaLabel: '能力与工具设置', skills: 'Skills', - mcp: 'MCP' + mcp: 'MCP', + tools: '工具执行环境' } }, actions: { diff --git a/src/renderer/src/i18n/locales/zh-CN/settingsSections.ts b/src/renderer/src/i18n/locales/zh-CN/settingsSections.ts index fd68ae88..a41f221f 100644 --- a/src/renderer/src/i18n/locales/zh-CN/settingsSections.ts +++ b/src/renderer/src/i18n/locales/zh-CN/settingsSections.ts @@ -684,6 +684,110 @@ export const settingsSections = { assignedTo: '分配给', deleteAria: '删除 {{name}}' }, + toolEnvironment: { + loading: '正在读取工具执行环境…', + scope: + '工具执行环境仅用于本机项目的新建本地 Runtime 进程和 stdio MCP Server;不会改变终端环境,也不会应用到远程 Host。', + runtimeSourceLegend: '{{runtime}} 执行来源', + sources: { + managed: 'GoodBuddy 托管', + custom: '自定义环境', + customDescription: '使用你选择的本机可执行文件。' + }, + runtimes: { + node: { + title: 'Node.js', + description: '用于需要 Node.js、npm 或 npx 的本机工具。', + managedDescription: + '使用 GoodBuddy 随应用托管的 Node.js;无需单独安装或移除。' + }, + python: { + title: 'Python', + description: '用于需要 Python 或 pip 的本机工具。', + managedDescription: + '使用 GoodBuddy 托管的 Python;可按需安装、更新或移除。' + } + }, + downloadSource: { + title: '工具下载源', + description: + '只控制 GoodBuddy 托管的工具执行环境制品下载,不影响模型、应用更新、终端或远程 Host。', + legend: '选择工具制品下载源', + noFallback: + 'GoodBuddy 只访问所选来源;失败时不会自动回退到另一个来源。', + options: { + native: { + label: '原生地址', + description: '使用 GoodBuddy 默认工具制品来源。' + }, + oss: { + label: 'OSS 镜像', + description: '使用面向内网或镜像网络的 OSS 工具制品来源。' + } + } + }, + selectedPath: '当前自定义路径', + candidateList: '{{runtime}} 可执行文件候选', + noCandidates: + '未发现候选文件。可刷新候选,或从系统文件选择器选择可执行文件。', + toolStatus: '工具状态', + companionStatus: '工具链/配套工具状态', + capabilityDependencyStatus: '能力依赖状态', + capabilityDependenciesUnverified: + '尚未获得具体能力的依赖验证证据。', + status: '状态', + available: '可用', + unavailable: '不可用', + version: '版本', + path: '路径', + detail: '详情', + notDiagnosed: '尚未诊断', + python: { + installed: 'GoodBuddy 托管的 Python {{version}} 已安装', + notInstalled: 'GoodBuddy 托管的 Python {{version}} 尚未安装', + progressLabel: 'Python 安装或更新进度', + progressSource: '下载源:{{source}}', + removeConfirmation: + '移除后,本机 Runtime 和 stdio MCP 将无法使用此受管 Python,直到重新安装。自定义 Python 不受影响。', + phases: { + downloading: '正在下载', + extracting: '正在解压', + validating: '正在验证', + publishing: '正在安装' + } + }, + actions: { + retry: '重试', + diagnose: '诊断', + diagnosing: '诊断中…', + diagnoseAll: '诊断全部', + refreshCandidates: '刷新候选', + chooseFile: '选择可执行文件', + installPython: '安装 Python', + updatePython: '更新 Python', + cancel: '取消', + removePython: '移除 Python', + confirmRemovePython: '确认移除 Python' + }, + errors: { + unavailable: '当前版本未提供工具执行环境服务', + readFailed: '读取工具执行环境失败', + saveFailed: '保存工具执行环境设置失败;已恢复此前确认的设置', + refreshFailed: '刷新可执行文件候选失败', + selectFailed: '选择 {{runtime}} 可执行文件失败', + diagnoseFailed: '诊断 {{runtime}} 失败', + diagnoseAllFailed: '诊断工具执行环境失败', + installPythonFailed: '安装或更新 Python 失败', + cancelPythonFailed: '取消 Python 操作失败', + removePythonFailed: '移除 Python 失败' + }, + notifications: { + runtimeChanged: '{{runtime}} 执行来源已更新', + downloadSourceChanged: '工具下载源已更新', + pythonInstalled: 'GoodBuddy 托管的 Python 已安装或更新', + pythonRemoved: 'GoodBuddy 托管的 Python 已移除' + } + }, feedback: { entry: { title: '帮助改进 GoodBuddy', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 3fbde33d..07fc75ed 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -10453,6 +10453,258 @@ details.settings-section > :not(summary) + :not(summary) { display: none; } +.tool-environment { + display: grid; + min-width: 0; + gap: var(--space-4); +} + +.tool-environment__scope, +.tool-environment__error, +.tool-environment__load-error { + padding: var(--space-3); + margin: 0; + border: 1px solid var(--border-default); + border-radius: var(--radius-control); + background: var(--surface-subtle); + color: var(--text-secondary); + font-size: var(--font-body); + line-height: 1.55; +} + +.tool-environment__error, +.tool-environment__load-error { + border-color: var(--danger-border); + background: var(--danger-subtle); + color: var(--danger); +} + +.tool-environment__load-error p { + margin: 0 0 var(--space-3); +} + +.tool-environment-card { + display: grid; + min-width: 0; + padding: var(--space-4); + border: 1px solid var(--border-default); + border-radius: var(--radius-card); + background: var(--surface-raised); + gap: var(--space-4); +} + +.tool-environment-card__header, +.tool-environment-card__actions { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.tool-environment-card__header { + justify-content: space-between; +} + +.tool-environment-card__header h3, +.tool-environment-status h4 { + margin: 0; + color: var(--text-primary); +} + +.tool-environment-card__header h3 { + font-size: var(--font-section-title); +} + +.tool-environment-card__header p, +.tool-environment-muted, +.tool-environment-status p, +.tool-environment-python p { + margin: var(--space-1) 0 0; + color: var(--text-secondary); + font-size: var(--font-body); + line-height: 1.5; +} + +.tool-environment-options { + display: grid; + padding: 0; + border: 0; + margin: 0; + gap: var(--space-2); +} + +.tool-environment-options legend { + padding: 0; + margin-bottom: var(--space-2); + color: var(--text-primary); + font-size: var(--font-body); + font-weight: 600; +} + +.tool-environment-option { + display: flex; + min-width: 0; + align-items: flex-start; + padding: var(--space-3); + border: 1px solid var(--border-control); + border-radius: var(--radius-control); + background: var(--surface-subtle); + cursor: pointer; + gap: var(--space-2); +} + +.tool-environment-option:has(input:checked) { + border-color: var(--accent); + background: var(--accent-subtle); +} + +.tool-environment-option:has(input:disabled) { + cursor: not-allowed; + opacity: 0.65; +} + +.tool-environment-option input { + margin-top: var(--space-1); +} + +.tool-environment-option span, +.tool-environment-card__custom, +.tool-environment-status, +.tool-environment-python, +.tool-environment-progress { + display: grid; + min-width: 0; + gap: var(--space-1); +} + +.tool-environment-option strong, +.tool-environment-status strong { + color: var(--text-primary); + font-size: var(--font-body); +} + +.tool-environment-option small, +.tool-environment-status small, +.tool-environment-progress > span { + color: var(--text-muted); + font-size: var(--font-caption); + line-height: 1.5; +} + +.tool-environment-path { + display: grid; + min-width: 0; + margin: var(--space-2) 0 0; + color: var(--text-secondary); + font-size: var(--font-caption); + gap: var(--space-1); +} + +.tool-environment-path code, +.tool-environment-python > code, +.tool-environment-status code { + color: var(--text-primary); + font-family: var(--font-family-mono); + overflow-wrap: anywhere; +} + +.tool-environment-candidates, +.tool-environment-status ul { + display: grid; + padding: 0; + margin: var(--space-2) 0 0; + list-style: none; + gap: var(--space-2); +} + +.tool-environment-candidates button { + display: grid; + width: 100%; + min-width: 0; + padding: var(--space-2); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-control); + background: var(--surface-subtle); + color: var(--text-primary); + text-align: left; + gap: var(--space-1); +} + +.tool-environment-candidates button[aria-pressed='true'] { + border-color: var(--accent); + background: var(--accent-subtle); +} + +.tool-environment-candidates code { + font-family: var(--font-family-mono); + font-size: var(--font-caption); + overflow-wrap: anywhere; +} + +.tool-environment-candidates small { + color: var(--text-muted); + font-size: var(--font-caption); +} + +.tool-environment-status { + padding-top: var(--space-3); + border-top: 1px solid var(--border-subtle); +} + +.tool-environment-status h4 { + font-size: var(--font-body); +} + +.tool-environment-status dl { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + margin: var(--space-2) 0 0; + color: var(--text-secondary); + font-size: var(--font-caption); + gap: var(--space-1) var(--space-3); +} + +.tool-environment-status dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +.tool-environment-status li { + display: grid; + grid-template-columns: minmax(60px, auto) minmax(0, 1fr); + align-items: baseline; + gap: var(--space-1) var(--space-2); +} + +.tool-environment-status li small { + grid-column: 1 / -1; +} + +.tool-environment-progress__track { + height: var(--space-2); + overflow: hidden; + border-radius: var(--radius-control); + background: var(--surface-muted); +} + +.tool-environment-progress__track > span { + display: block; + height: 100%; + background: var(--accent-solid); +} + +@media (max-width: 720px) { + .tool-environment-card__header { + align-items: stretch; + flex-direction: column; + } + + .tool-environment-card__actions { + align-items: stretch; + flex-direction: column; + } +} + .mcp-tool-section { display: flex; flex-direction: column; diff --git a/src/shared/application-settings-contracts.ts b/src/shared/application-settings-contracts.ts index 5d32b6eb..209588e3 100644 --- a/src/shared/application-settings-contracts.ts +++ b/src/shared/application-settings-contracts.ts @@ -1,8 +1,19 @@ import { z } from 'zod' import { magicNoteCommentFormatSchema } from './magic-notes-contracts' +import { localToolEnvironmentSettingsSchema } from './local-tool-environment-contracts' import { modelDownloadSourceSchema } from './model-download-contracts' import { settingsWarningsSchema } from './settings-warning-contracts' +export { + artifactDownloadSourceSchema, + defaultLocalToolEnvironmentSettings, + localToolEnvironmentSettingsSchema, + localToolExecutablePathSchema, + localToolRuntimeSelectionSchema, + type ArtifactDownloadSource, + type LocalToolEnvironmentSettings, + type LocalToolRuntimeSelection +} from './local-tool-environment-contracts' export { modelDownloadSourceSchema, type ModelDownloadSource @@ -14,14 +25,9 @@ export const magicNoteCommentModeSchema = z.enum([ 'after-save-manual' ]) -export type MagicNoteCommentMode = z.infer< - typeof magicNoteCommentModeSchema -> +export type MagicNoteCommentMode = z.infer -export const updateSourceSchema = z.enum([ - 'github', - 'mirror' -]) +export const updateSourceSchema = z.enum(['github', 'mirror']) export type UpdateSource = z.infer const applicationPreferencesSchema = z @@ -29,6 +35,7 @@ const applicationPreferencesSchema = z checkUpdatesOnStartup: z.boolean(), updateSource: updateSourceSchema, modelDownloadSource: modelDownloadSourceSchema, + localToolEnvironment: localToolEnvironmentSettingsSchema, remoteProjectsEnabled: z.boolean(), magicNotesEnabled: z.boolean(), magicNotesShowIncompleteTodoCount: z.boolean(), @@ -49,15 +56,12 @@ export const applicationSettingsUpdateSchema = applicationPreferencesSchema message: 'At least one application setting is required' }) -export type ApplicationSettings = z.infer< - typeof applicationSettingsSchema -> +export type ApplicationSettings = z.infer export type ApplicationSettingsUpdate = z.infer< typeof applicationSettingsUpdateSchema > - export type VersionCheckFile = { name: string size: number diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index db7ea212..caca8022 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -84,6 +84,13 @@ import type { ModelDownloadSource, VersionCheckResult } from './application-settings-contracts' +import type { + LocalToolDiagnoseTarget, + LocalToolEnvironmentProgress, + LocalToolEnvironmentSettings, + LocalToolEnvironmentSnapshot, + LocalToolKind +} from './local-tool-environment-contracts' import type { GlobalShortcutRegistrationStatus, GlobalShortcutSettings, @@ -1682,6 +1689,25 @@ export type DesktopApi = { listener: (result: VersionCheckResult) => void ) => () => void } + localToolEnvironment?: { + getSnapshot: () => Promise + updateSettings: ( + settings: LocalToolEnvironmentSettings + ) => Promise + refreshCandidates: () => Promise + selectExecutable: ( + kind: LocalToolKind + ) => Promise + diagnose: ( + kind: LocalToolDiagnoseTarget + ) => Promise + installPython: () => Promise + cancelPython: () => Promise + removePython: () => Promise + onProgress: ( + listener: (progress: LocalToolEnvironmentProgress) => void + ) => () => void + } feedback: { submit: ( input: FeedbackSubmitInput diff --git a/src/shared/goodbuddy-config-contracts.test.ts b/src/shared/goodbuddy-config-contracts.test.ts index d9840e53..fa197c0a 100644 --- a/src/shared/goodbuddy-config-contracts.test.ts +++ b/src/shared/goodbuddy-config-contracts.test.ts @@ -14,6 +14,7 @@ import { goodbuddyConfigToolByName, goodbuddyConfigTools } from './goodbuddy-config-tools' +import { defaultLocalToolEnvironmentSettings } from './local-tool-environment-contracts' describe('GoodBuddy configuration contracts', () => { it('publishes one valid generated example for every operation', () => { @@ -135,6 +136,7 @@ describe('GoodBuddy configuration contracts', () => { checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', + localToolEnvironment: defaultLocalToolEnvironmentSettings, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 4e3c7974..641abaf0 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -89,6 +89,19 @@ export const ipcChannels = { remoteChannelActivity: 'channels:remote-activity', applicationSettingsGet: 'settings:application:get', applicationSettingsUpdate: 'settings:application:update', + localToolEnvironmentGet: 'settings:local-tools:get', + localToolEnvironmentUpdate: 'settings:local-tools:update', + localToolEnvironmentRefresh: 'settings:local-tools:refresh', + localToolEnvironmentSelectExecutable: + 'settings:local-tools:select-executable', + localToolEnvironmentDiagnose: 'settings:local-tools:diagnose', + localToolEnvironmentInstallPython: + 'settings:local-tools:python:install', + localToolEnvironmentCancelPython: + 'settings:local-tools:python:cancel', + localToolEnvironmentRemovePython: + 'settings:local-tools:python:remove', + localToolEnvironmentProgress: 'settings:local-tools:progress', shortcutSettingsGet: 'settings:shortcut:get', shortcutSettingsUpdate: 'settings:shortcut:update', versionCheck: 'application:update:check', diff --git a/src/shared/local-tool-environment-contracts.test.ts b/src/shared/local-tool-environment-contracts.test.ts new file mode 100644 index 00000000..020a9b2b --- /dev/null +++ b/src/shared/local-tool-environment-contracts.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { + artifactDownloadSourceSchema, + defaultLocalToolEnvironmentSettings, + localToolCandidateSchema, + localToolEnvironmentSettingsSchema, + localToolPythonOperationSchema, + localToolRuntimeSelectionSchema +} from './local-tool-environment-contracts' + +describe('local tool environment contracts', () => { + it('accepts managed defaults and both download sources', () => { + expect( + localToolEnvironmentSettingsSchema.parse( + defaultLocalToolEnvironmentSettings + ) + ).toEqual(defaultLocalToolEnvironmentSettings) + expect(artifactDownloadSourceSchema.parse('oss')).toBe('oss') + }) + + it('accepts cross-platform absolute custom executable paths', () => { + for (const executablePath of [ + '/usr/local/bin/node', + 'C:\\Program Files\\nodejs\\node.exe', + '\\\\server\\tools\\python.exe' + ]) { + expect( + localToolRuntimeSelectionSchema.parse({ + source: 'custom', + executablePath + }) + ).toEqual({ source: 'custom', executablePath }) + } + }) + + it('strictly rejects relative, unbounded, and mismatched selections', () => { + for (const input of [ + { source: 'custom', executablePath: 'bin/node' }, + { source: 'custom', executablePath: '/bin/node ' }, + { source: 'custom', executablePath: `/${'x'.repeat(4_096)}` }, + { source: 'custom' }, + { source: 'managed', executablePath: '/bin/node' }, + { source: 'automatic' } + ]) { + expect(localToolRuntimeSelectionSchema.safeParse(input).success).toBe( + false + ) + } + }) + + it('strictly rejects incomplete and unknown settings', () => { + expect( + localToolEnvironmentSettingsSchema.safeParse({ + ...defaultLocalToolEnvironmentSettings, + extra: true + }).success + ).toBe(false) + expect( + localToolEnvironmentSettingsSchema.safeParse({ + node: { source: 'managed' }, + python: { source: 'managed' } + }).success + ).toBe(false) + expect(artifactDownloadSourceSchema.safeParse('mirror').success).toBe(false) + }) + + it('requires validated candidate version and architecture metadata', () => { + expect( + localToolCandidateSchema.parse({ + kind: 'node', + executablePath: 'C:\\Tools\\node.exe', + version: '22.14.0', + architecture: 'x64' + }) + ).toEqual({ + kind: 'node', + executablePath: 'C:\\Tools\\node.exe', + version: '22.14.0', + architecture: 'x64' + }) + expect( + localToolCandidateSchema.safeParse({ + kind: 'python', + executablePath: '/usr/bin/python3' + }).success + ).toBe(false) + }) + + it('freezes the download source into Python operation snapshots', () => { + expect( + localToolPythonOperationSchema.parse({ + source: 'oss', + phase: 'downloading', + receivedBytes: 10, + totalBytes: 100 + }) + ).toEqual({ + source: 'oss', + phase: 'downloading', + receivedBytes: 10, + totalBytes: 100 + }) + expect( + localToolPythonOperationSchema.safeParse({ + phase: 'validating' + }).success + ).toBe(false) + }) +}) diff --git a/src/shared/local-tool-environment-contracts.ts b/src/shared/local-tool-environment-contracts.ts new file mode 100644 index 00000000..29370cc5 --- /dev/null +++ b/src/shared/local-tool-environment-contracts.ts @@ -0,0 +1,155 @@ +import { z } from 'zod' + +const absoluteExecutablePathPattern = + /^(?:\/|[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+(?:[\\/]|$))/u + +export const localToolExecutablePathSchema = z + .string() + .min(1) + .max(4_096) + .refine((value) => value.trim() === value, { + message: 'Executable path must not have surrounding whitespace' + }) + .refine((value) => !value.includes('\0'), { + message: 'Executable path must not contain null characters' + }) + .regex(absoluteExecutablePathPattern, 'Executable path must be absolute') + +export const localToolRuntimeSelectionSchema = z.discriminatedUnion('source', [ + z.object({ source: z.literal('managed') }).strict(), + z + .object({ + source: z.literal('custom'), + executablePath: localToolExecutablePathSchema + }) + .strict() +]) + +export const artifactDownloadSourceSchema = z.enum(['native', 'oss']) + +export const localToolEnvironmentSettingsSchema = z + .object({ + node: localToolRuntimeSelectionSchema, + python: localToolRuntimeSelectionSchema, + artifactDownloadSource: artifactDownloadSourceSchema + }) + .strict() + +export const defaultLocalToolEnvironmentSettings: LocalToolEnvironmentSettings = + { + node: { source: 'managed' }, + python: { source: 'managed' }, + artifactDownloadSource: 'native' + } + +export type LocalToolRuntimeSelection = z.infer< + typeof localToolRuntimeSelectionSchema +> +export type ArtifactDownloadSource = z.infer< + typeof artifactDownloadSourceSchema +> +export type LocalToolEnvironmentSettings = z.infer< + typeof localToolEnvironmentSettingsSchema +> + +export const localToolKindSchema = z.enum(['node', 'python']) +export type LocalToolKind = z.infer + +export const localToolDiagnoseTargetSchema = z.enum([ + 'node', + 'python', + 'all' +]) +export type LocalToolDiagnoseTarget = z.infer< + typeof localToolDiagnoseTargetSchema +> + +export const localToolKindInputSchema = z + .object({ kind: localToolKindSchema }) + .strict() +export const localToolDiagnoseInputSchema = z + .object({ kind: localToolDiagnoseTargetSchema }) + .strict() + +export const localToolCandidateSchema = z + .object({ + kind: localToolKindSchema, + executablePath: localToolExecutablePathSchema, + version: z + .string() + .regex(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u), + architecture: z + .string() + .min(1) + .max(64) + .regex(/^[0-9A-Za-z][0-9A-Za-z._-]*$/u) + }) + .strict() +export type LocalToolCandidate = z.infer + +export const localToolDiagnosticSchema = z + .object({ + available: z.boolean(), + source: z.enum(['managed', 'custom']), + version: z.string().max(160).optional(), + executablePath: localToolExecutablePathSchema.optional(), + detail: z.string().max(512) + }) + .strict() +export type LocalToolDiagnosticSnapshot = z.infer< + typeof localToolDiagnosticSchema +> + +export const localToolPythonOperationSchema = z + .object({ + source: artifactDownloadSourceSchema, + phase: z.enum([ + 'downloading', + 'extracting', + 'validating', + 'publishing' + ]), + receivedBytes: z.number().int().nonnegative().safe().optional(), + totalBytes: z.number().int().positive().safe().optional() + }) + .strict() +export type LocalToolPythonOperation = z.infer< + typeof localToolPythonOperationSchema +> + +export const localToolEnvironmentSnapshotSchema = z + .object({ + settings: localToolEnvironmentSettingsSchema, + candidates: z.array(localToolCandidateSchema).max(40), + diagnostics: z + .object({ + node: localToolDiagnosticSchema.optional(), + npm: localToolDiagnosticSchema.optional(), + npx: localToolDiagnosticSchema.optional(), + python: localToolDiagnosticSchema.optional(), + python3: localToolDiagnosticSchema.optional(), + pip: localToolDiagnosticSchema.optional() + }) + .strict(), + managedPython: z + .object({ + version: z.string().regex(/^\d+\.\d+\.\d+$/u), + installed: z.boolean(), + executablePath: localToolExecutablePathSchema.optional(), + operation: localToolPythonOperationSchema.optional() + }) + .strict() + }) + .strict() +export type LocalToolEnvironmentSnapshot = z.infer< + typeof localToolEnvironmentSnapshotSchema +> + +export const localToolEnvironmentProgressSchema = z + .object({ + snapshot: localToolEnvironmentSnapshotSchema + }) + .strict() +export type LocalToolEnvironmentProgress = z.infer< + typeof localToolEnvironmentProgressSchema +> diff --git a/tests/build-release.test.ts b/tests/build-release.test.ts index a0cd350c..c879c0fd 100644 --- a/tests/build-release.test.ts +++ b/tests/build-release.test.ts @@ -243,6 +243,10 @@ function portableDirectory(parent: string): string { join(directory, 'resources', 'runtimes', 'continue'), { recursive: true } ) + mkdirSync( + join(directory, 'resources', 'tool-environment'), + { recursive: true } + ) mkdirSync( join( directory, @@ -270,12 +274,20 @@ function portableDirectory(parent: string): string { ['resources/release-notes.json', '{}'], ['resources/icon.ico', 'icon'], ['resources/tray-icon.png', 'tray'], + [ + 'resources/tool-environment/managed-python-artifacts.json', + '{"artifacts":[]}' + ], ['resources/runtimes/opencode/opencode.exe', 'MZ'], ['resources/runtimes/continue/package.json', '{}'], [ 'resources/runtimes/npm/bin/npm-cli.js', 'require("../lib/cli.js")' ], + [ + 'resources/runtimes/npm/bin/npx-cli.js', + 'require("../lib/cli.js")' + ], [ 'resources/runtimes/npm/package.json', '{"version":"11.19.0"}' @@ -786,11 +798,21 @@ describe('release build arguments', () => { '!node_modules/npm{,/**/*}' ]) expect(resourceSources).toContain('resources/skills') + expect(packageJson.build.extraResources).toContainEqual({ + from: 'resources/tool-environment/managed-python-artifacts.json', + to: 'tool-environment/managed-python-artifacts.json' + }) expect(packageJson.build.extraResources).toContainEqual({ from: 'node_modules', to: 'runtimes', filter: ['npm{,/**/*}'] }) + expect( + existsSync(join('node_modules', 'npm', 'bin', 'npm-cli.js')) + ).toBe(true) + expect( + existsSync(join('node_modules', 'npm', 'bin', 'npx-cli.js')) + ).toBe(true) expect(resourceSources).toContain( 'resources/agent-release-keys.json' ) diff --git a/tests/local-tool-environment-live.test.ts b/tests/local-tool-environment-live.test.ts new file mode 100644 index 00000000..0938bbb6 --- /dev/null +++ b/tests/local-tool-environment-live.test.ts @@ -0,0 +1,92 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { + defaultLocalToolEnvironmentSettings, + type LocalToolEnvironmentSettings +} from '../src/shared/local-tool-environment-contracts' +import { LocalToolEnvironmentService } from '../src/main/local-tool-environment/local-tool-environment-service' +import { buildCredentialFilteredUserEnvironment } from '../src/main/agent/process-environment' + +const runLive = process.env.GOODBUDDY_RUN_LIVE_TOOL_ENVIRONMENT === '1' +const describeLive = runLive ? describe : describe.skip +let root: string | undefined + +afterAll(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + } +}) + +describeLive('live local tool environment', () => { + it('installs and validates the pinned Managed Python artifact', async () => { + root = await mkdtemp(join(tmpdir(), 'goodbuddy-live-tools-')) + let settings: LocalToolEnvironmentSettings = { + ...defaultLocalToolEnvironmentSettings, + artifactDownloadSource: 'native' + } + const service = new LocalToolEnvironmentService({ + settingsStore: { + get: async () => ({ localToolEnvironment: settings }), + update: async (input: { + localToolEnvironment: LocalToolEnvironmentSettings + }) => { + settings = input.localToolEnvironment + return { localToolEnvironment: settings } + } + } as never, + binDirectory: join(root, 'bin'), + managedPythonRoot: join(root, 'managed-python'), + pythonArtifactCatalogPath: join( + process.cwd(), + 'resources', + 'tool-environment', + 'managed-python-artifacts.json' + ), + packagedNpmCliPath: join( + process.cwd(), + 'node_modules', + 'npm', + 'bin', + 'npm-cli.js' + ), + packagedNpxCliPath: join( + process.cwd(), + 'node_modules', + 'npm', + 'bin', + 'npx-cli.js' + ), + selectExecutable: async () => undefined, + electronExecutablePath: join( + process.cwd(), + 'node_modules', + 'electron', + 'dist', + process.platform === 'win32' ? 'electron.exe' : 'electron' + ), + baseEnvironment: buildCredentialFilteredUserEnvironment() + }) + + try { + await service.initialize() + const snapshot = await service.installPython() + expect(snapshot.managedPython).toMatchObject({ + version: '3.13.15', + installed: true + }) + expect(snapshot.diagnostics.python).toMatchObject({ + available: true, + source: 'managed', + version: '3.13.15' + }) + expect(snapshot.diagnostics.pip).toMatchObject({ + available: true, + source: 'managed' + }) + } finally { + await service.dispose() + } + }, 180_000) +})