feat: bundle private Windows runtimes with settings switches
This commit is contained in:
1 parent
759df7060d
commit
bef87520c2
28 files changed
+902
-10
No files matched your search
@@ -9,3 +9,8 @@ release/
|
|||||||
.env.local
|
.env.local
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Reproducibly downloaded Windows toolchains; keep manifest and notices tracked.
|
||||||
|
resources/runtimes/win32-x64/
|
||||||
|
resources/runtimes/win32-x64.previous/
|
||||||
|
resources/runtimes/.prepare-*/
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Windows x64 内置工具链
|
||||||
|
|
||||||
|
## 固定来源与布局
|
||||||
|
|
||||||
|
`resources/runtimes/manifest.json` 固定 Node 24.22.0 LTS(npm/npx 11.19.1)、CPython 3.14.8(pip 26.2.1)和 PortableGit 2.56.0.windows.2(含 Git Bash)。Python 选择仍受维护的 3.14 稳定分支,不追随刚发布的 3.15。
|
||||||
|
|
||||||
|
安装后位于 `resources/runtimes/win32-x64/{node,python,git}`,不在 asar 内。Node 使用 `node.exe`、`npm.cmd`、`npx.cmd`;Python 使用 `python.exe -m pip`,命令行便利入口为 `Scripts/pip.cmd` / `pip3.cmd`;Git 使用 `cmd/git.exe`、`bin/bash.exe`(保留完整 MSYS2 依赖树)。不安装系统工具、不修改注册表或系统/用户 PATH、不做全局包安装。
|
||||||
|
|
||||||
|
## 可重现准备与打包
|
||||||
|
|
||||||
|
开发机先安装项目依赖(现有项目的 electron-vite / vite peer 范围冲突需使用该项目已有兼容策略,例如 `npm ci --legacy-peer-deps`)。然后:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run runtimes:prepare
|
||||||
|
npm run runtimes:verify
|
||||||
|
npm run dist:installer
|
||||||
|
```
|
||||||
|
|
||||||
|
`dist` 和 `dist:installer` 自动准备所有工具链并限定 x64;electron-builder `beforePack` 再验所有文件、缺失/损坏/非 Windows x64 均拒绝打包。直接运行 builder 不会自动联网,需先准备。源码仓库不保存大二进制,下载缓存位于 `.tmp/runtime-downloads/`(可预置同名官方包离线构建),提取结果被 Git 忽略。下载失败或哈希不符不退回系统工具、不生成空壳包。
|
||||||
|
|
||||||
|
Node 使用官方 SHASUMS256.txt;Git 使用官方 GitHub release asset SHA256;Python 使用 NuGet 官方不可变 catalog SHA512,来源 URL 随锁文件保存。每次先验证压缩包,再由锁定的构建依赖 `7zip-bin` 提取;不会运行 PortableGit 自解压 EXE。提取后的每个文件(相对路径、SHA256,确定顺序)组成 tree hash,必须匹配已审查 manifest。更新版本需重新审查来源/hash/完整提取内容并更新 tree hash;脚本不会自动改锁。
|
||||||
|
|
||||||
|
保留所有上游许可证,见随包 THIRD-PARTY-NOTICES.md;公开分发前还需完成 GPL 对应源码交付/书面提供等适用义务审查。
|
||||||
|
|
||||||
|
## Python / pip / venv
|
||||||
|
|
||||||
|
采用 Python 官方 NuGet 完整运行时,而非不支持常规 pip/venv 的 embeddable 包。实包包含 `Lib/venv`、`Lib/ensurepip`、ensurepip 内置 pip wheel 和已安装的 pip。pip.cmd 使用相对目录调用 python.exe,迁移安装目录后仍定位自身 Python,无硬编码构建机路径。调用 pip 不需要启动时联网 bootstrap。建议项目执行:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
"<runtime>\python\python.exe" -m venv ".venv"
|
||||||
|
".venv\Scripts\python.exe" -m pip install <package>
|
||||||
|
```
|
||||||
|
|
||||||
|
虚拟环境是用户工作区数据,不能作为可搬移运行时打包;移动 YCode 或跨版本更新后应重建依赖其路径的 venv。缺少匹配 wheel 的原生扩展可能仍需要外部编译工具;本包不含 MSVC/完整开发 SDK,不承诺任意 Python/npm 包离线可装。基础运行时应视为只读,包依赖安装到项目 venv/项目 node_modules 或应用私有路径,而非应用安装目录。
|
||||||
|
|
||||||
|
## 验证边界
|
||||||
|
|
||||||
|
Linux 构建机可确认三份真实官方包下载、发布哈希、完整解包树哈希、PE/MZ 文件头、关键路径、版本元数据和许可证。不能把这些检查宣称为 Windows 执行成功。发布前仍须在干净 Windows x64 上验证 Node/npm/npx、Python/pip/创建 venv、Git/Bash,含空格/中文路径、开关禁用、升级和无系统工具情形。不能只验 `--version` 后声称所有包管理/沙盒链路已完成。
|
||||||
|
|
||||||
|
### PortableGit 提取后处理
|
||||||
|
|
||||||
|
实际 2.56 包使用 `ucrt64` 而非旧 `mingw64`。准备脚本按上游 `13-copy-dlls.post` 的语义,将 ucrt64/bin DLL 复制至 ucrt64/libexec/git-core(非依赖硬链接),保留 dlls-copied 标记与 dev/shm、dev/mqueue 目录。上游 post-install 脚本移入 `ycode-upstream-post-install` 审计目录,避免 Bash login 首次执行时修改安装资源。不会从构建机复制 hosts/protocols/services/networks;不会伪造 Windows 的 /etc/mtab MSYS 符号链接。这些非核心 POSIX 系统集成没有宣称完成,需按实际用途在 Windows 验证。
|
||||||
|
|
||||||
|
### 原生离线 smoke
|
||||||
|
|
||||||
|
Windows x64 运行 `npm run runtimes:smoke`,或 `node scripts/smoke-runtime-bundle.mjs "安装目录/resources/runtimes/win32-x64"`。仅在临时目录创建 venv/Git repo,无网络、全局安装或凭据操作;验证所有命令版本、venv 自带 pip、Git init 和 Bash printf,并复核资源无变化。Linux 明确输出 SKIP,不代表通过。跨目录搬移、Bash login、Git HTTPS 和实际包下载仍需单独原生测试。
|
||||||
|
|
||||||
|
## 安全中心开关与使用边界
|
||||||
|
|
||||||
|
设置 → 安全中心仅新增“内置运行时”卡片,包括总开关和 Node.js、Python、Git Bash 独立开关。偏好存入应用 userData/private-runtimes.json,原子写入、串行更新;文件损坏或不可读不会静默恢复为启用。关总开关保留单项选择。
|
||||||
|
|
||||||
|
仅对之后启动的终端、Claude 内核和 MCP stdio 子进程注入工具路径;现有进程不强制重启。关闭是停用随包工具,不是禁止系统工具或增加权限隔离。MCP 保留最小继承环境,终端继续过滤凭据;不更改发送、排队、审批或回合策略。
|
||||||
|
|
||||||
|
npm 的 prefix/cache 与 Python 的 PYTHONUSERBASE/pip cache 位于 userData/runtime-data 下,仅在相应内置工具启用时注入;不设置 PYTHONHOME、PIP_USER 或 PIP_TARGET,以免破坏项目 venv。Python 项目优先使用 venv;独立用户安装可显式使用 `python -m pip install --user <package>`。PYTHONUSERBASE 本身不保证所有裸 `pip install` 都采用 --user,安装目录可写时 pip 仍可能写入基础运行时,因此不要直接往随包环境安装依赖。
|
||||||
|
|
||||||
|
## 开发回归入口
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node scripts/private-runtimes-regression.cjs
|
||||||
|
node --test scripts/runtime-bundle.test.mjs
|
||||||
|
# jsdom 不属于产品依赖;设置为测试环境已有模块的绝对路径
|
||||||
|
YCODE_TEST_DOM_MODULE=/path/to/jsdom node scripts/test-private-runtimes-dom.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
这些离线测试分别覆盖环境/持久化、打包防护和设置界面交互;不能替代 Windows 二进制执行与安装包验收。
|
||||||
Generated
+8
@@ -48,6 +48,7 @@
|
|||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@types/svg-pan-zoom": "^3.3.0",
|
"@types/svg-pan-zoom": "^3.3.0",
|
||||||
"@vitejs/plugin-react": "^6.1.2",
|
"@vitejs/plugin-react": "^6.1.2",
|
||||||
|
"7zip-bin": "5.2.0",
|
||||||
"electron": "^37.10.3",
|
"electron": "^37.10.3",
|
||||||
"electron-builder": "^26.15.3",
|
"electron-builder": "^26.15.3",
|
||||||
"electron-vite": "^5.0.0",
|
"electron-vite": "^5.0.0",
|
||||||
@@ -3214,6 +3215,13 @@
|
|||||||
"addons/*"
|
"addons/*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/7zip-bin": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/abbrev": {
|
"node_modules/abbrev": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-4.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-4.0.0.tgz",
|
||||||
|
|||||||
+19
-4
@@ -42,8 +42,8 @@
|
|||||||
"test:sessions": "esbuild scripts/session-store-regression.ts --bundle --platform=node --format=esm --external:esbuild --outfile=.tmp/session-store-regression.mjs --log-level=warning && node .tmp/session-store-regression.mjs",
|
"test:sessions": "esbuild scripts/session-store-regression.ts --bundle --platform=node --format=esm --external:esbuild --outfile=.tmp/session-store-regression.mjs --log-level=warning && node .tmp/session-store-regression.mjs",
|
||||||
"shot": "node scripts/shot.mjs",
|
"shot": "node scripts/shot.mjs",
|
||||||
"icon:build": "node scripts/gen-icon.mjs",
|
"icon:build": "node scripts/gen-icon.mjs",
|
||||||
"dist": "npm run build && electron-builder --win dir",
|
"dist": "npm run runtimes:prepare && npm run build && electron-builder --win dir --x64",
|
||||||
"dist:installer": "npm run build && electron-builder --win nsis",
|
"dist:installer": "npm run runtimes:prepare && npm run build && electron-builder --win nsis --x64",
|
||||||
"release": "node scripts/updates/release.mjs",
|
"release": "node scripts/updates/release.mjs",
|
||||||
"test:release": "node scripts/updates/release-selftest.mjs",
|
"test:release": "node scripts/updates/release-selftest.mjs",
|
||||||
"test:updates": "esbuild scripts/update-backend-regression.ts scripts/update-maintenance-regression.ts --bundle --platform=node --format=esm --out-extension:.js=.mjs --outdir=.tmp/update-tests --log-level=warning && node .tmp/update-tests/update-backend-regression.mjs && node .tmp/update-tests/update-maintenance-regression.mjs && node scripts/updates/selftest.mjs && node scripts/updates/contract-selftest.mjs && node scripts/updates/nsis-safety-regression.mjs",
|
"test:updates": "esbuild scripts/update-backend-regression.ts scripts/update-maintenance-regression.ts --bundle --platform=node --format=esm --out-extension:.js=.mjs --outdir=.tmp/update-tests --log-level=warning && node .tmp/update-tests/update-backend-regression.mjs && node .tmp/update-tests/update-maintenance-regression.mjs && node scripts/updates/selftest.mjs && node scripts/updates/contract-selftest.mjs && node scripts/updates/nsis-safety-regression.mjs",
|
||||||
@@ -95,7 +95,11 @@
|
|||||||
"test:fold-summary": "node scripts/rea/recovered-fold-summary-regression.mjs",
|
"test:fold-summary": "node scripts/rea/recovered-fold-summary-regression.mjs",
|
||||||
"test:tool-styles": "node scripts/rea/tool-result-styles-regression.mjs",
|
"test:tool-styles": "node scripts/rea/tool-result-styles-regression.mjs",
|
||||||
"test:workbuddy-import": "esbuild scripts/workbuddy-import-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/workbuddy-import-regression.mjs --log-level=warning && node .tmp/workbuddy-import-regression.mjs",
|
"test:workbuddy-import": "esbuild scripts/workbuddy-import-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/workbuddy-import-regression.mjs --log-level=warning && node .tmp/workbuddy-import-regression.mjs",
|
||||||
"test:rea-conversation": "node scripts/rea/selected-conversation-regression.mjs"
|
"test:rea-conversation": "node scripts/rea/selected-conversation-regression.mjs",
|
||||||
|
"runtimes:prepare": "node scripts/prepare-runtime-bundle.mjs",
|
||||||
|
"runtimes:verify": "node scripts/verify-runtime-bundle.mjs",
|
||||||
|
"runtimes:smoke": "node scripts/smoke-runtime-bundle.mjs",
|
||||||
|
"test:runtime-bundle": "node --test scripts/runtime-bundle.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.304",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.304",
|
||||||
@@ -138,6 +142,7 @@
|
|||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@types/svg-pan-zoom": "^3.3.0",
|
"@types/svg-pan-zoom": "^3.3.0",
|
||||||
"@vitejs/plugin-react": "^6.1.2",
|
"@vitejs/plugin-react": "^6.1.2",
|
||||||
|
"7zip-bin": "5.2.0",
|
||||||
"electron": "^37.10.3",
|
"electron": "^37.10.3",
|
||||||
"electron-builder": "^26.15.3",
|
"electron-builder": "^26.15.3",
|
||||||
"electron-vite": "^5.0.0",
|
"electron-vite": "^5.0.0",
|
||||||
@@ -192,6 +197,15 @@
|
|||||||
{
|
{
|
||||||
"from": "resources/tray.png",
|
"from": "resources/tray.png",
|
||||||
"to": "tray.png"
|
"to": "tray.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/runtimes",
|
||||||
|
"to": "runtimes",
|
||||||
|
"filter": [
|
||||||
|
"manifest.json",
|
||||||
|
"THIRD-PARTY-NOTICES.md",
|
||||||
|
"win32-x64/**/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"win": {
|
"win": {
|
||||||
@@ -210,6 +224,7 @@
|
|||||||
"createDesktopShortcut": true,
|
"createDesktopShortcut": true,
|
||||||
"createStartMenuShortcut": true,
|
"createStartMenuShortcut": true,
|
||||||
"shortcutName": "YCode"
|
"shortcutName": "YCode"
|
||||||
}
|
},
|
||||||
|
"beforePack": "./scripts/runtime-before-pack.cjs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Bundled Windows x64 toolchains
|
||||||
|
|
||||||
|
Official vendor archives are redistributed with their entire license and notice trees, except Node's optional PowerShell command wrappers (removed to allow `.cmd` use under normal Restricted policy). No application feature changes PowerShell policy or machine/user PATH.
|
||||||
|
|
||||||
|
- **Node.js 24.22.0 (LTS)**, with **npm/npx 11.19.1**. Upstream: https://nodejs.org/dist/v24.22.0/ . Node's MIT and bundled third-party terms are in `win32-x64/node/LICENSE`; npm and dependencies preserve their own license files under `node_modules/npm/`. Source: https://github.com/nodejs/node/tree/v24.22.0 and https://github.com/npm/cli/tree/v11.19.1 .
|
||||||
|
- **CPython 3.14.8**, PSF's official `python` NuGet x64 package, with **pip 26.2.1**. License: `win32-x64/python/LICENSE.txt`; pip and vendored component licenses remain under `Lib/site-packages/`. Source: https://github.com/python/cpython/tree/v3.14.8 and https://github.com/pypa/pip/tree/26.2.1 . Distribution documented by https://docs.python.org/3.14/using/windows.html#the-nuget-org-packages . This is the full build-oriented package, NOT Python's limited embeddable ZIP.
|
||||||
|
- **Git for Windows 2.56.0.windows.2 PortableGit**, including Bash/MSYS2, not MinGit. GPL and component licenses/notices remain throughout `win32-x64/git/`, including `LICENSE.txt` and `usr/share/licenses/`. Official release and corresponding source archives: https://github.com/git-for-windows/git/releases/tag/v2.56.0.windows.2 . Component source and build packaging: https://github.com/git-for-windows/build-extra and https://github.com/git-for-windows/MSYS2-packages . Redistributors must satisfy all applicable copyleft source requirements; these links identify upstream sources, not a substitute for a required source offer or corresponding-source delivery. Review redistribution compliance before public release.
|
||||||
|
|
||||||
|
Exact download URLs, vendor-published hashes, and extracted-tree hashes are in `manifest.json`. Toolchain licenses are not replaced by YCode's terms. The preparation tool does not execute any downloaded Windows executable.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"platform": "win32",
|
||||||
|
"arch": "x64",
|
||||||
|
"runtimes": [
|
||||||
|
{
|
||||||
|
"id": "node",
|
||||||
|
"version": "24.22.0",
|
||||||
|
"npmVersion": "11.19.1",
|
||||||
|
"archive": "node.zip",
|
||||||
|
"url": "https://nodejs.org/dist/v24.22.0/node-v24.22.0-win-x64.zip",
|
||||||
|
"algorithm": "sha256",
|
||||||
|
"digest": "f9683a19877e12a9b167305c8db71040d812876359de21f0d2e97c3f321f80c3",
|
||||||
|
"digestEncoding": "hex",
|
||||||
|
"checksumSource": "https://nodejs.org/dist/v24.22.0/SHASUMS256.txt",
|
||||||
|
"prefix": "node-v24.22.0-win-x64",
|
||||||
|
"required": [
|
||||||
|
"node.exe",
|
||||||
|
"npm.cmd",
|
||||||
|
"npx.cmd",
|
||||||
|
"node_modules/npm/bin/npm-cli.js",
|
||||||
|
"node_modules/npm/bin/npx-cli.js",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"treeSha256": "5cb4f80767f68d2c32d28fa4dbc51a3c00681e72e60288a783692df99458e53f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python",
|
||||||
|
"version": "3.14.8",
|
||||||
|
"pipVersion": "26.2.1",
|
||||||
|
"archive": "python.nupkg",
|
||||||
|
"url": "https://api.nuget.org/v3-flatcontainer/python/3.14.8/python.3.14.8.nupkg",
|
||||||
|
"algorithm": "sha512",
|
||||||
|
"digest": "b9oz/C2qiLlE7FKAogz3dyiT38vFX3d9K2IqhooLZmcL3E9REJsgrg3eUXeTHLXRy50jgfHEoX1bx3u91Vjeaw==",
|
||||||
|
"digestEncoding": "base64",
|
||||||
|
"checksumSource": "https://api.nuget.org/v3/catalog0/data/2026.09.30.20.00.00/python.3.14.8.json",
|
||||||
|
"prefix": "tools",
|
||||||
|
"required": [
|
||||||
|
"python.exe",
|
||||||
|
"python314.dll",
|
||||||
|
"Lib/os.py",
|
||||||
|
"Lib/site-packages/pip/__main__.py",
|
||||||
|
"Lib/venv/__init__.py",
|
||||||
|
"Lib/ensurepip/__init__.py",
|
||||||
|
"Lib/ensurepip/_bundled/pip-26.2.1-py3-none-any.whl",
|
||||||
|
"Scripts/pip.cmd",
|
||||||
|
"LICENSE.txt"
|
||||||
|
],
|
||||||
|
"treeSha256": "70d377bec4650e9a72cfc1395733c5d3a1010ecf597fb72e3a8460ef6ca14162"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "git",
|
||||||
|
"version": "2.56.0.windows.2",
|
||||||
|
"archive": "git.7z.exe",
|
||||||
|
"url": "https://github.com/git-for-windows/git/releases/download/v2.56.0.windows.2/PortableGit-2.56.0.2-64-bit.7z.exe",
|
||||||
|
"algorithm": "sha256",
|
||||||
|
"digest": "075e158ef8e1f0ab80b347e245405d3eca735c2dc88fd8e032e137d0ca61f61b",
|
||||||
|
"digestEncoding": "hex",
|
||||||
|
"checksumSource": "https://api.github.com/repos/git-for-windows/git/releases/tags/v2.56.0.windows.2",
|
||||||
|
"prefix": "",
|
||||||
|
"required": [
|
||||||
|
"cmd/git.exe",
|
||||||
|
"bin/bash.exe",
|
||||||
|
"usr/bin/bash.exe",
|
||||||
|
"usr/bin/msys-2.0.dll",
|
||||||
|
"etc/gitconfig",
|
||||||
|
"LICENSE.txt",
|
||||||
|
"ucrt64/libexec/git-core/dlls-copied",
|
||||||
|
"ucrt64/libexec/git-core/git-remote-https.exe"
|
||||||
|
],
|
||||||
|
"treeSha256": "8c7762472d6c1bb90c8a9b623fc2baf3a112efb108fbd2dacf32120b67a05349"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -62,3 +62,7 @@ T01–T03 正式入口约定:模式标签通过 Node 构建夹具,再启动
|
|||||||
3. 必要断言不能为变绿删除;失败区分产品缺陷与环境缺失。
|
3. 必要断言不能为变绿删除;失败区分产品缺陷与环境缺失。
|
||||||
4. 删除前检查 npm 入口、import/require、runner 动态文件列表、夹具、恢复链及当前文档;仅历史记录引用不等于运行依赖,但须给恢复路径。
|
4. 删除前检查 npm 入口、import/require、runner 动态文件列表、夹具、恢复链及当前文档;仅历史记录引用不等于运行依赖,但须给恢复路径。
|
||||||
5. 提交测试证据时写明受测代码 SHA、实际命令和未验证范围。默认基线通过不自动关闭需求验收。
|
5. 提交测试证据时写明受测代码 SHA、实际命令和未验证范围。默认基线通过不自动关闭需求验收。
|
||||||
|
|
||||||
|
### Windows 私有运行时
|
||||||
|
|
||||||
|
相关变更使用 `node scripts/private-runtimes-regression.cjs`、`node --test scripts/runtime-bundle.test.mjs` 和配置 `YCODE_TEST_DOM_MODULE` 后的 `node scripts/test-private-runtimes-dom.mjs`。`runtimes:prepare/verify` 校验实际包;`runtimes:smoke` 仅在 Windows x64 执行真实工具,在其他平台明确 SKIP。见 [Windows 内置工具链](../docs/Windows内置工具链.md),不计入默认离线核心测试的 Windows 覆盖。
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Downloads official pinned archives only. Never executes downloaded Windows files.
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import sevenZip from '7zip-bin'
|
||||||
|
import { transformRuntime } from './runtime-transform.mjs'
|
||||||
|
import { projectRoot, runtimeRoot, manifest, digest, inventory, verifyRuntimeBundle } from './runtime-bundle.mjs'
|
||||||
|
const cache = path.join(projectRoot, '.tmp/runtime-downloads')
|
||||||
|
await fs.mkdir(cache, { recursive: true })
|
||||||
|
const stage = await fs.mkdtemp(path.join(runtimeRoot, '.prepare-'))
|
||||||
|
try {
|
||||||
|
if (process.platform !== 'win32') await fs.chmod(sevenZip.path7za, 0o755)
|
||||||
|
for (const runtime of manifest.runtimes) {
|
||||||
|
const archive = path.join(cache, runtime.archive)
|
||||||
|
let bytes = await fs.readFile(archive).catch(() => null)
|
||||||
|
if (!bytes) {
|
||||||
|
console.log(`Downloading ${runtime.id} ${runtime.version} from ${runtime.url}`)
|
||||||
|
const response = await fetch(runtime.url, { signal: AbortSignal.timeout(300_000) })
|
||||||
|
if (!response.ok) throw new Error(`Download failed: ${response.status} ${runtime.url}`)
|
||||||
|
bytes = Buffer.from(await response.arrayBuffer())
|
||||||
|
}
|
||||||
|
if (digest(bytes, runtime.algorithm, runtime.digestEncoding) !== runtime.digest) throw new Error(`Archive integrity failure: ${runtime.id}; remove ${archive} to redownload`)
|
||||||
|
await fs.writeFile(archive, bytes)
|
||||||
|
const unpack = path.join(stage, `unpack-${runtime.id}`)
|
||||||
|
await fs.mkdir(unpack)
|
||||||
|
const result = spawnSync(sevenZip.path7za, ['x', archive, `-o${unpack}`, '-y'], { encoding: 'utf8', windowsHide: true })
|
||||||
|
if (result.error || result.status !== 0) throw new Error(`Archive extraction failed: ${runtime.id}: ${result.error || result.stderr || result.stdout}`)
|
||||||
|
const target = path.join(stage, runtime.id)
|
||||||
|
await fs.rename(path.join(unpack, runtime.prefix), target)
|
||||||
|
await transformRuntime(runtime.id, target)
|
||||||
|
// No loose mutation of the extraction lock. A source update requires reviewed tree digest changes.
|
||||||
|
const tree = digest(JSON.stringify(await inventory(target)))
|
||||||
|
if (runtime.treeSha256 && tree !== runtime.treeSha256) throw new Error(`Extracted ${runtime.id} tree mismatch: ${tree}`)
|
||||||
|
if (!runtime.treeSha256) throw new Error(`Unpinned ${runtime.id} extracted tree: ${tree}`)
|
||||||
|
}
|
||||||
|
await verifyRuntimeBundle(stage)
|
||||||
|
const destination = path.join(runtimeRoot, 'win32-x64')
|
||||||
|
const previous = `${destination}.previous`
|
||||||
|
await fs.rm(previous, { recursive: true, force: true })
|
||||||
|
const exists = await fs.stat(destination).then(() => true, () => false)
|
||||||
|
if (exists) await fs.rename(destination, previous)
|
||||||
|
try {
|
||||||
|
await fs.mkdir(destination)
|
||||||
|
for (const runtime of manifest.runtimes) await fs.rename(path.join(stage, runtime.id), path.join(destination, runtime.id))
|
||||||
|
} catch (error) {
|
||||||
|
await fs.rm(destination, { recursive: true, force: true })
|
||||||
|
if (exists) await fs.rename(previous, destination)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
await fs.rm(previous, { recursive: true, force: true })
|
||||||
|
console.log('Windows x64 runtimes prepared; native Windows smoke tests still required.')
|
||||||
|
} finally {
|
||||||
|
await fs.rm(stage, { recursive: true, force: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs/promises')
|
||||||
|
const path = require('node:path')
|
||||||
|
const os = require('node:os')
|
||||||
|
const { build } = require('esbuild')
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'ycode-private-runtimes-'))
|
||||||
|
let checks = 0
|
||||||
|
const check = (name, run) => { run(); checks++; console.log('PASS', name) }
|
||||||
|
try {
|
||||||
|
const pureFile = path.join(temp, 'pure.cjs')
|
||||||
|
await build({ entryPoints: ['src/main/private-runtime-environment.ts'], outfile: pureFile, bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent' })
|
||||||
|
const { normalizePrivateRuntimePreferences: normalize, validatePrivateRuntimePatch: validate, privateRuntimeView: view, applyPrivateRuntimeEnvironment: apply } = require(pureFile)
|
||||||
|
const root = 'C:\\YCode Private\\resources\\runtimes\\win32-x64'
|
||||||
|
const defaults = normalize(undefined)
|
||||||
|
const all = view('win32', root, defaults, () => true)
|
||||||
|
const base = Object.freeze({ Path: 'C:\\System Tools;C:\\Windows', SystemRoot: 'C:\\Windows', TERM: 'xterm' })
|
||||||
|
check('defaults and complete bundle activate all tools', () => assert.ok(all.tools.every(tool => tool.active)))
|
||||||
|
check('private tools precede original system paths, including spaces', () => {
|
||||||
|
const result = apply(base, all)
|
||||||
|
assert.ok(result.PATH.startsWith(root + '\\node;'))
|
||||||
|
assert.ok(result.PATH.endsWith(base.Path))
|
||||||
|
assert.ok(result.PATH.includes(root + '\\python\\Scripts'))
|
||||||
|
assert.equal(result.CLAUDE_CODE_GIT_BASH_PATH, root + '\\git\\bin\\bash.exe')
|
||||||
|
assert.equal(result.Path, undefined)
|
||||||
|
assert.equal(base.Path, 'C:\\System Tools;C:\\Windows')
|
||||||
|
})
|
||||||
|
check('disabled global preference preserves original environment exactly', () => assert.deepEqual(apply(base, view('win32', root, normalize({ enabled: false }), () => true)), base))
|
||||||
|
check('per-tool disabled does not inject its paths', () => {
|
||||||
|
const result = apply(base, view('win32', root, normalize({ tools: { node: false, git: false } }), () => true))
|
||||||
|
assert.ok(!result.PATH.includes('\\node'))
|
||||||
|
assert.ok(!result.PATH.includes('\\git'))
|
||||||
|
assert.equal(result.CLAUDE_CODE_GIT_BASH_PATH, undefined)
|
||||||
|
assert.ok(result.PATH.startsWith(root + '\\python;'))
|
||||||
|
})
|
||||||
|
check('missing and incomplete bundle never reports active', () => {
|
||||||
|
for (const missing of ['node.exe', 'npm.cmd', 'npx.cmd', 'python.exe', 'pip.cmd', 'git.exe', 'bash.exe']) {
|
||||||
|
const state = view('win32', root, defaults, file => !file.endsWith(missing))
|
||||||
|
assert.equal(state.tools.filter(tool => !tool.available).length, 1)
|
||||||
|
assert.ok(state.tools.filter(tool => !tool.available).every(tool => !tool.active && tool.path === null))
|
||||||
|
}
|
||||||
|
assert.deepEqual(apply(base, view('win32', root, defaults, () => false)), base)
|
||||||
|
})
|
||||||
|
check('Linux and macOS preserve original environment', () => {
|
||||||
|
for (const platform of ['linux', 'darwin']) {
|
||||||
|
const state = view(platform, root, defaults, () => true)
|
||||||
|
assert.equal(state.supported, false)
|
||||||
|
assert.deepEqual(apply(base, state), base)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
check('case-insensitive PATH becomes one canonical key and survives MCP SDK merge', () => {
|
||||||
|
const result = apply({ PATH: 'C:\\System', Path: 'C:\\Explicit', path: 'c:\\system', claude_code_git_bash_path: 'old' }, all)
|
||||||
|
assert.deepEqual(Object.keys(result).filter(key => key.toLowerCase() === 'path'), ['PATH'])
|
||||||
|
assert.equal(result.PATH.split(';').filter(item => item.toLowerCase() === 'c:\\system').length, 1)
|
||||||
|
assert.ok(({ PATH: 'C:\\System', ...result }).PATH.startsWith(root))
|
||||||
|
assert.equal(result.claude_code_git_bash_path, undefined)
|
||||||
|
})
|
||||||
|
check('overlay introduces no credentials or unrelated variables', () => {
|
||||||
|
const result = apply({ PATH: 'C:\\System' }, all)
|
||||||
|
assert.deepEqual(Object.keys(result).sort(), ['CLAUDE_CODE_GIT_BASH_PATH', 'PATH'])
|
||||||
|
})
|
||||||
|
check('writable private caches and prefixes are scoped to active tools', () => {
|
||||||
|
const dataRoot = 'C:\\Users\\test\\AppData\\YCode\\runtime-data'
|
||||||
|
const result = apply({ ...base, npm_config_prefix: 'system', pythonuserbase: 'system' }, all, dataRoot)
|
||||||
|
assert.equal(result.NPM_CONFIG_PREFIX, dataRoot + '\\node\\global')
|
||||||
|
assert.equal(result.NPM_CONFIG_CACHE, dataRoot + '\\node\\cache')
|
||||||
|
assert.equal(result.PYTHONUSERBASE, dataRoot + '\\python\\user')
|
||||||
|
assert.equal(result.PIP_CACHE_DIR, dataRoot + '\\python\\cache')
|
||||||
|
assert.ok(result.PATH.includes(dataRoot + '\\python\\user\\Python314\\Scripts'))
|
||||||
|
assert.equal(result.PYTHONHOME, undefined)
|
||||||
|
assert.equal(result.PIP_USER, undefined)
|
||||||
|
assert.equal(result.PIP_TARGET, undefined)
|
||||||
|
assert.equal(result.npm_config_prefix, undefined)
|
||||||
|
assert.equal(result.pythonuserbase, undefined)
|
||||||
|
assert.deepEqual(apply(base, view('win32', root, normalize({ enabled: false }), () => true), dataRoot), base)
|
||||||
|
const gitOnly = apply(base, view('win32', root, normalize({ tools: { node: false, python: false } }), () => true), dataRoot)
|
||||||
|
assert.equal(gitOnly.NPM_CONFIG_PREFIX, undefined)
|
||||||
|
assert.equal(gitOnly.PYTHONUSERBASE, undefined)
|
||||||
|
})
|
||||||
|
check('overlay idempotence', () => assert.deepEqual(apply(apply(base, all), all), apply(base, all)))
|
||||||
|
check('IPC patch validation rejects malformed and unknown values', () => {
|
||||||
|
for (const bad of [null, [], 'yes', { enabled: 'true' }, { tools: null }, { tools: [] }, { tools: { ruby: true } }, { tools: { node: 1 } }, { root: 'C:\\evil' }]) assert.throws(() => validate(bad))
|
||||||
|
assert.deepEqual(validate({ enabled: false, tools: { node: true } }), { enabled: false, tools: { node: true } })
|
||||||
|
})
|
||||||
|
const userData = path.join(temp, 'user-data')
|
||||||
|
await fs.mkdir(userData)
|
||||||
|
await fs.writeFile(path.join(userData, 'config.json'), JSON.stringify({ apiKeyPlain: 'persistence-only-test', unrelated: 'preserved' }))
|
||||||
|
async function service(name) {
|
||||||
|
const outfile = path.join(temp, name + '.cjs')
|
||||||
|
await build({ entryPoints: ['src/main/private-runtimes.ts'], outfile, bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent', plugins: [{ name: 'isolated-electron', setup(build) {
|
||||||
|
build.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'mock' }))
|
||||||
|
build.onLoad({ filter: /.*/, namespace: 'mock' }, () => ({ contents: `export const app = { isPackaged:false,getPath:()=>${JSON.stringify(userData)},getAppPath:()=>${JSON.stringify(temp)} };export const safeStorage={};`, loader: 'js' }))
|
||||||
|
} }] })
|
||||||
|
return require(outfile)
|
||||||
|
}
|
||||||
|
const api = await service('service')
|
||||||
|
await Promise.all([api.updatePrivateRuntimes({ tools: { node: false } }), api.updatePrivateRuntimes({ tools: { git: false } }), api.updatePrivateRuntimes({ enabled: false })])
|
||||||
|
const saved = JSON.parse(await fs.readFile(path.join(userData, 'private-runtimes.json'), 'utf8'))
|
||||||
|
const unrelated = JSON.parse(await fs.readFile(path.join(userData, 'config.json'), 'utf8'))
|
||||||
|
check('serialized updates merge toggles and preserve unrelated config', () => {
|
||||||
|
assert.deepEqual(saved, { enabled: false, tools: { node: false, python: true, git: false } })
|
||||||
|
assert.equal(unrelated.unrelated, 'preserved')
|
||||||
|
assert.equal(unrelated.apiKeyPlain, 'persistence-only-test')
|
||||||
|
})
|
||||||
|
const fresh = await service('reloaded')
|
||||||
|
check('settings survive module restart without exposing config credentials', () => {
|
||||||
|
assert.ok(!JSON.stringify(all).includes('persistence-only-test'))
|
||||||
|
})
|
||||||
|
assert.deepEqual((await fresh.getPrivateRuntimes()).preferences, saved)
|
||||||
|
assert.ok(!JSON.stringify(await fresh.getPrivateRuntimes()).includes('persistence-only-test'))
|
||||||
|
// Force temporary-file creation failure; the committed preferences remain readable.
|
||||||
|
await fs.mkdir(path.join(userData, 'private-runtimes.json.tmp'))
|
||||||
|
await assert.rejects(api.updatePrivateRuntimes({ enabled: true }))
|
||||||
|
assert.equal((await api.getPrivateRuntimes()).preferences.enabled, false)
|
||||||
|
check('failed atomic save leaves the committed enable policy unchanged', () => {})
|
||||||
|
await fs.rm(path.join(userData, 'private-runtimes.json.tmp'), { recursive: true })
|
||||||
|
await api.updatePrivateRuntimes({ enabled: true })
|
||||||
|
assert.equal((await api.getPrivateRuntimes()).preferences.enabled, true)
|
||||||
|
const settingsPath = path.join(userData, 'private-runtimes.json')
|
||||||
|
const validSettings = await fs.readFile(settingsPath, 'utf8')
|
||||||
|
for (const corrupt of ['{broken', '{}', 'null', '{"enabled":"true","tools":{}}']) {
|
||||||
|
await fs.writeFile(settingsPath, corrupt)
|
||||||
|
await assert.rejects(api.getPrivateRuntimes())
|
||||||
|
await assert.rejects(api.updatePrivateRuntimes({ enabled: true }))
|
||||||
|
}
|
||||||
|
await fs.rm(settingsPath)
|
||||||
|
await fs.mkdir(settingsPath)
|
||||||
|
await assert.rejects(api.getPrivateRuntimes())
|
||||||
|
await fs.rm(settingsPath, { recursive: true })
|
||||||
|
await fs.writeFile(settingsPath, validSettings)
|
||||||
|
check('corrupt, incomplete, and unreadable stored policy never silently enables tools', () => {})
|
||||||
|
console.log(`Private runtimes: ${checks} regression groups passed; no Windows executables run.`)
|
||||||
|
} finally { await fs.rm(temp, { recursive: true, force: true }) }
|
||||||
|
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = async function beforePack(context) {
|
||||||
|
if (context.electronPlatformName !== 'win32' || context.arch !== 1) {
|
||||||
|
throw new Error('YCode bundled runtimes support Windows x64 only; do not ship x64 runtimes for another target.')
|
||||||
|
}
|
||||||
|
const { verifyRuntimeBundle } = await import('./runtime-bundle.mjs')
|
||||||
|
await verifyRuntimeBundle()
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
export const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
export const runtimeRoot = path.join(projectRoot, 'resources/runtimes')
|
||||||
|
export const manifest = JSON.parse(await fs.readFile(path.join(runtimeRoot, 'manifest.json'), 'utf8'))
|
||||||
|
export const digest = (bytes, algorithm = 'sha256', encoding = 'hex') => crypto.createHash(algorithm).update(bytes).digest(encoding)
|
||||||
|
export async function inventory(root) {
|
||||||
|
const files = {}
|
||||||
|
async function visit(dir) {
|
||||||
|
for (const entry of (await fs.readdir(dir, { withFileTypes: true })).sort((a,b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
|
||||||
|
const absolute = path.join(dir, entry.name)
|
||||||
|
if (entry.isSymbolicLink()) throw new Error(`Runtime symlink is not allowed: ${absolute}`)
|
||||||
|
if (entry.isDirectory()) await visit(absolute)
|
||||||
|
else if (entry.isFile()) files[path.relative(root, absolute).split(path.sep).join('/')] = digest(await fs.readFile(absolute))
|
||||||
|
else throw new Error(`Unsupported runtime entry: ${absolute}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await visit(root)
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
export async function verifyRuntimeBundle(root = path.join(runtimeRoot, 'win32-x64')) {
|
||||||
|
for (const runtime of manifest.runtimes) {
|
||||||
|
const dir = path.join(root, runtime.id)
|
||||||
|
for (const required of runtime.required) {
|
||||||
|
const file = path.join(dir, required)
|
||||||
|
const bytes = await fs.readFile(file).catch(() => { throw new Error(`Missing bundled ${runtime.id}: ${required}. Run npm run runtimes:prepare.`) })
|
||||||
|
if (!bytes.length) throw new Error(`Empty runtime asset: ${file}`)
|
||||||
|
if (required.endsWith('.exe')) {
|
||||||
|
if (bytes.length < 64 || bytes.subarray(0, 2).toString() !== 'MZ') throw new Error(`Invalid Windows executable: ${file}`)
|
||||||
|
const pe = bytes.readUInt32LE(0x3c)
|
||||||
|
if (pe > bytes.length - 6 || bytes.subarray(pe, pe + 4).toString('hex') !== '50450000' || bytes.readUInt16LE(pe + 4) !== 0x8664) {
|
||||||
|
throw new Error(`Runtime executable is not Windows x64 PE: ${file}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const files = await inventory(dir)
|
||||||
|
const actual = digest(JSON.stringify(files))
|
||||||
|
if (!runtime.treeSha256 || actual !== runtime.treeSha256) throw new Error(`Bundled ${runtime.id} tree checksum mismatch: ${actual}; expected ${runtime.treeSha256}. Run npm run runtimes:prepare.`)
|
||||||
|
console.log(`${runtime.id} ${runtime.version}: verified ${Object.keys(files).length} files (${actual})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { digest, inventory, manifest, verifyRuntimeBundle } from './runtime-bundle.mjs'
|
||||||
|
import { transformRuntime } from './runtime-transform.mjs'
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
test('source lock uses reviewed official HTTPS endpoints and fixed digests', () => {
|
||||||
|
for (const r of manifest.runtimes) {
|
||||||
|
assert.ok(['nodejs.org','api.nuget.org','github.com'].includes(new URL(r.url).hostname))
|
||||||
|
assert.equal(new URL(r.url).protocol, 'https:')
|
||||||
|
assert.match(r.treeSha256, /^[a-f0-9]{64}$/)
|
||||||
|
assert.equal(Buffer.from(r.digest, r.digestEncoding).length, r.algorithm === 'sha512' ? 64 : 32)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test('inventory is deterministic and notices content changes', async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ycode-inventory-'))
|
||||||
|
try {
|
||||||
|
await fs.writeFile(path.join(root, 'a'), 'one')
|
||||||
|
const first = digest(JSON.stringify(await inventory(root)))
|
||||||
|
assert.equal(first, digest(JSON.stringify(await inventory(root))))
|
||||||
|
await fs.writeFile(path.join(root, 'a'), 'two')
|
||||||
|
assert.notEqual(first, digest(JSON.stringify(await inventory(root))))
|
||||||
|
await assert.rejects(verifyRuntimeBundle(root), /Missing bundled/)
|
||||||
|
} finally { await fs.rm(root, { recursive:true, force:true }) }
|
||||||
|
})
|
||||||
|
test('Python convenience launcher is relocatable and uses module pip', async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ycode-python-'))
|
||||||
|
try {
|
||||||
|
await transformRuntime('python', root)
|
||||||
|
assert.equal(await fs.readFile(path.join(root,'Scripts/pip.cmd'),'utf8'), '@echo off\r\n"%~dp0..\\python.exe" -m pip %*\r\n')
|
||||||
|
} finally { await fs.rm(root,{recursive:true,force:true}) }
|
||||||
|
})
|
||||||
|
test('packaging rejects wrong platform and wrong arch', async () => {
|
||||||
|
const beforePack = require('./runtime-before-pack.cjs')
|
||||||
|
await assert.rejects(beforePack({electronPlatformName:'win32',arch:3}), /Windows x64 only/)
|
||||||
|
await assert.rejects(beforePack({electronPlatformName:'linux',arch:1}), /Windows x64 only/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
// Deterministic extraction transforms. No native Windows programs run here.
|
||||||
|
export async function transformRuntime(id, target) {
|
||||||
|
if (id === 'node') {
|
||||||
|
for (const name of ['npm.ps1', 'npx.ps1']) await fs.rm(path.join(target, name), { force: true })
|
||||||
|
}
|
||||||
|
if (id === 'python') {
|
||||||
|
await fs.mkdir(path.join(target, 'Scripts'), { recursive: true })
|
||||||
|
const wrapper = '@echo off\r\n"%~dp0..\\python.exe" -m pip %*\r\n'
|
||||||
|
for (const name of ['pip.cmd', 'pip3.cmd']) await fs.writeFile(path.join(target, 'Scripts', name), wrapper)
|
||||||
|
}
|
||||||
|
if (id === 'git') {
|
||||||
|
// PortableGit's 13-copy-dlls.post normally hardlinks these after extraction.
|
||||||
|
// Copies are relocation-safe and survive installer/archive packaging.
|
||||||
|
const bin = path.join(target, 'ucrt64/bin')
|
||||||
|
const exec = path.join(target, 'ucrt64/libexec/git-core')
|
||||||
|
for (const name of (await fs.readdir(bin)).filter(name => name.endsWith('.dll')).sort()) {
|
||||||
|
await fs.copyFile(path.join(bin, name), path.join(exec, name))
|
||||||
|
}
|
||||||
|
await fs.copyFile(path.join(bin, 'git.exe'), path.join(exec, 'dlls-copied'))
|
||||||
|
for (const name of ['shm', 'mqueue']) {
|
||||||
|
await fs.mkdir(path.join(target, 'dev', name), { recursive: true })
|
||||||
|
await fs.writeFile(path.join(target, 'dev', name, 'YCODE-KEEP.txt'), '')
|
||||||
|
}
|
||||||
|
const audit = path.join(target, 'ycode-upstream-post-install')
|
||||||
|
await fs.mkdir(audit, { recursive: true })
|
||||||
|
await fs.rename(path.join(target, 'etc/post-install'), path.join(audit, 'scripts'))
|
||||||
|
await fs.rename(path.join(target, 'post-install.bat'), path.join(audit, 'post-install.bat'))
|
||||||
|
// Do not snapshot build-host /etc or synthesize Windows MSYS symlinks on Linux.
|
||||||
|
// Upstream scripts are moved out of etc/post-install so login Bash cannot mutate resources.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Offline, isolated native smoke. Never call this a Windows pass on another OS.
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import os from 'node:os'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { runtimeRoot, verifyRuntimeBundle } from './runtime-bundle.mjs'
|
||||||
|
if (process.platform !== 'win32' || process.arch !== 'x64') {
|
||||||
|
console.log('SKIP: native toolchain smoke requires Windows x64; no Windows binaries executed.')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
const root = path.resolve(process.argv[2] || path.join(runtimeRoot, 'win32-x64'))
|
||||||
|
await verifyRuntimeBundle(root)
|
||||||
|
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'YCode runtime 中文 '))
|
||||||
|
try {
|
||||||
|
const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(?:path|home|userprofile|temp|tmp|python.*|pip_.*|npm_config_.*|node_options|git_.*)$/i.test(key)))
|
||||||
|
const env = { ...inherited, HOME: temp, USERPROFILE: temp, TEMP: temp, TMP: temp,
|
||||||
|
npm_config_userconfig: path.join(temp, 'npmrc'), npm_config_globalconfig: path.join(temp, 'npm-globalrc'),
|
||||||
|
npm_config_cache: path.join(temp, 'npm-cache'), npm_config_prefix: path.join(temp, 'npm-prefix'),
|
||||||
|
PYTHONUSERBASE: path.join(temp, 'python-user'), PIP_CACHE_DIR: path.join(temp, 'pip-cache'),
|
||||||
|
PYTHONNOUSERSITE: '1', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: path.join(temp, 'gitconfig'),
|
||||||
|
PATH: [path.join(root, 'node'), path.join(root, 'python'), path.join(root, 'git/cmd'), path.join(root, 'git/bin'), path.join(process.env.SystemRoot || 'C:\\Windows', 'System32')].join(';') }
|
||||||
|
const run = (exe, args) => {
|
||||||
|
const r = spawnSync(exe, args, { cwd: temp, env, encoding: 'utf8', timeout: 60_000, windowsHide: true })
|
||||||
|
if (r.error || r.status !== 0) throw new Error(`${exe} ${args.join(' ')} failed: ${r.error || r.stderr || r.stdout}`)
|
||||||
|
console.log(r.stdout.trim())
|
||||||
|
}
|
||||||
|
const node = path.join(root, 'node/node.exe'), python = path.join(root, 'python/python.exe')
|
||||||
|
run(node, ['--version'])
|
||||||
|
// Explicit cmd.exe, fixed arguments only; downloaded npm/npx scripts never use shell interpolation from user input.
|
||||||
|
for (const command of ['npm', 'npx']) run(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `""${path.join(root, `node/${command}.cmd`)}" --version"`])
|
||||||
|
run(python, ['--version']); run(python, ['-m', 'pip', '--version'])
|
||||||
|
run(python, ['-m', 'venv', path.join(temp, 'venv')])
|
||||||
|
run(path.join(temp, 'venv/Scripts/python.exe'), ['-m', 'pip', '--version'])
|
||||||
|
const git = path.join(root, 'git/cmd/git.exe'), bash = path.join(root, 'git/bin/bash.exe')
|
||||||
|
run(git, ['--version']); run(git, ['init', '--quiet', path.join(temp, 'repo')])
|
||||||
|
run(bash, ['--version']); run(bash, ['--noprofile', '--norc', '-c', 'printf "ycode-bash-ok\\n"'])
|
||||||
|
await verifyRuntimeBundle(root)
|
||||||
|
console.log('PASS: offline Windows x64 runtime smoke; resources unchanged. Network package installs and HTTPS Git remain untested.')
|
||||||
|
} finally { await fs.rm(temp, { recursive: true, force: true }) }
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
const { JSDOM } = require(process.env.YCODE_TEST_DOM_MODULE || 'jsdom')
|
||||||
|
const output = path.resolve('.tmp/private-runtimes-dom')
|
||||||
|
fs.mkdirSync(output, { recursive: true })
|
||||||
|
fs.writeFileSync(path.join(output, 'fixture.tsx'), `import React from 'react';import {createRoot} from 'react-dom/client';import Panel from ${JSON.stringify(path.resolve('src/renderer/src/components/PrivateRuntimesSettingsPanel.tsx'))};let root;window.mount=()=>{root=createRoot(document.getElementById('root'));root.render(<React.StrictMode><Panel/></React.StrictMode>)};window.unmount=()=>root.unmount();window.mount();`)
|
||||||
|
await require('esbuild').build({entryPoints:[path.join(output,'fixture.tsx')],outfile:path.join(output,'fixture.js'),bundle:true,platform:'browser',format:'iife',jsx:'automatic',logLevel:'error'})
|
||||||
|
const dom = new JSDOM('<html><body><div id="root"></div></body></html>',{url:'https://fixture.invalid',runScripts:'outside-only',pretendToBeVisual:true})
|
||||||
|
const w=dom.window,d=w.document,checks=[],errors=[],calls=[]
|
||||||
|
const ids=['node','python','git']
|
||||||
|
let preferences={enabled:true,tools:{node:true,python:true,git:true}},supported=true,missing=[],failGet=false,failUpdate=false,hold=null,delay=false
|
||||||
|
const snapshot=()=>({platform:supported?'win32':'linux',supported,preferences:structuredClone(preferences),tools:ids.map(id=>({id,available:!missing.includes(id),enabled:preferences.tools[id],active:supported&&!missing.includes(id)&&preferences.enabled&&preferences.tools[id],path:missing.includes(id)?null:`C:\\runtimes\\${id}`}))})
|
||||||
|
w.ycode={privateRuntimes:{get:async()=>{if(failGet)throw Error('read failure');return snapshot()},update:async patch=>{calls.push(patch);if(delay)await new Promise(resolve=>hold=resolve);if(failUpdate)throw Error('write failure');preferences={...preferences,...patch,tools:{...preferences.tools,...patch.tools}};return snapshot()}}}
|
||||||
|
w.addEventListener('error',event=>errors.push(event.message))
|
||||||
|
const pause=()=>new Promise(resolve=>setTimeout(resolve,15))
|
||||||
|
const wait=async fn=>{for(let i=0;i<150;i++){if(fn())return;await pause()}throw Error('Timed out: '+fn)}
|
||||||
|
const check=(name,value)=>{assert.ok(value,name);checks.push(name)}
|
||||||
|
const control=name=>d.querySelector(`[aria-label="${name}"]`)
|
||||||
|
const master=()=>control('启用内置运行时'),node=()=>control('启用 Node.js'),python=()=>control('启用 Python')
|
||||||
|
const remount=async()=>{w.unmount();await pause();w.mount();await wait(()=>master()&&!d.body.textContent.includes('正在读取内置运行时…'));await pause()}
|
||||||
|
try {
|
||||||
|
w.eval(fs.readFileSync(path.join(output,'fixture.js'),'utf8'))
|
||||||
|
await wait(()=>master()&&!master().disabled)
|
||||||
|
check('exactly four accessible switches',d.querySelectorAll('[role=switch]').length===4)
|
||||||
|
check('ready status derives from backend',d.querySelector('[data-runtime=node] small').textContent.includes('已启用'))
|
||||||
|
delay=true;node().click();node().click();await wait(()=>hold)
|
||||||
|
check('duplicate clicks produce one patch',calls.length===1)
|
||||||
|
check('saving retains acknowledged value and disables all controls',node().checked&&[...d.querySelectorAll('input')].every(el=>el.disabled)&&d.body.textContent.includes('正在保存'))
|
||||||
|
hold();await wait(()=>!node().checked&&!node().disabled);delay=false
|
||||||
|
check('tool patch is narrow',JSON.stringify(calls[0])===JSON.stringify({tools:{node:false}}))
|
||||||
|
master().click();await wait(()=>!master().checked&&!master().disabled)
|
||||||
|
check('master off preserves individual choices',!node().checked&&python().checked&&node().disabled&&python().disabled)
|
||||||
|
master().click();await wait(()=>master().checked&&!node().disabled)
|
||||||
|
check('master on restores choices',!node().checked&&python().checked)
|
||||||
|
failUpdate=true;python().click();await wait(()=>d.querySelector('[role=alert]'))
|
||||||
|
check('failed save does not change persisted checkmark',python().checked&&preferences.tools.python&&d.body.textContent.includes('write failure'))
|
||||||
|
failUpdate=false;python().click();await wait(()=>!python().checked)
|
||||||
|
check('retry succeeds and clears error',!d.querySelector('[role=alert]'))
|
||||||
|
await remount();check('reopen reloads saved choices',!node().checked&&!python().checked&&master().checked)
|
||||||
|
missing=['git'];await remount()
|
||||||
|
check('missing bundle is disabled with honest status',control('启用 Git Bash').disabled&&d.querySelector('[data-runtime=git]').textContent.includes('未随包提供'))
|
||||||
|
supported=false;await remount()
|
||||||
|
check('non-Windows disables controls and explains support',[...d.querySelectorAll('input')].every(el=>el.disabled)&&d.body.textContent.includes('仅支持 Windows x64'))
|
||||||
|
failGet=true;await remount()
|
||||||
|
check('read failure offers recovery without enabling controls',d.body.textContent.includes('read failure')&&[...d.querySelectorAll('input')].every(el=>el.disabled))
|
||||||
|
failGet=false;supported=true;missing=[];d.querySelector('button').click();await wait(()=>!master().disabled)
|
||||||
|
check('read retry recovers actual state',!d.querySelector('[role=alert]')&&!node().checked)
|
||||||
|
delay=true;node().click();await wait(()=>hold);w.unmount();hold();await pause();delay=false;w.mount();await wait(()=>master()&&!master().disabled)
|
||||||
|
check('save acknowledged after navigation is read on reopen',node().checked)
|
||||||
|
check('no unrelated security controls',!d.body.textContent.includes('删除保护')&&!d.body.textContent.includes('审计中心'))
|
||||||
|
check('future process and system fallback warning present',d.body.textContent.includes('重新打开终端或启动新会话')&&d.body.textContent.includes('系统已安装的工具仍可能可用'))
|
||||||
|
check('no uncaught renderer errors',errors.length===0)
|
||||||
|
console.log(JSON.stringify({result:'PASS',checks,limits:'Controlled DOM/API fixture, not Windows execution or screenshot pixel parity.'},null,2))
|
||||||
|
}finally{w.unmount();dom.window.close()}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { verifyRuntimeBundle } from './runtime-bundle.mjs'
|
||||||
|
await verifyRuntimeBundle(process.argv[2])
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { privateRuntimeEnvironment } from '../../private-runtimes'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { existsSync, mkdirSync } from 'node:fs'
|
import { existsSync, mkdirSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
@@ -362,6 +363,7 @@ export class ClaudeKernel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async ensureStarted(): Promise<void> {
|
async ensureStarted(): Promise<void> {
|
||||||
|
const childEnvironment = await privateRuntimeEnvironment(modelEnvironment(this.options.model))
|
||||||
await this.permissionUpdates
|
await this.permissionUpdates
|
||||||
await this.draining
|
await this.draining
|
||||||
if (this.policyError) throw this.policyError
|
if (this.policyError) throw this.policyError
|
||||||
@@ -384,7 +386,7 @@ export class ClaudeKernel {
|
|||||||
cwd: this.options.workspaceRoot,
|
cwd: this.options.workspaceRoot,
|
||||||
model: this.options.model.config.model,
|
model: this.options.model.config.model,
|
||||||
pathToClaudeCodeExecutable: executable,
|
pathToClaudeCodeExecutable: executable,
|
||||||
env: modelEnvironment(this.options.model),
|
env: childEnvironment,
|
||||||
abortController: processAbort,
|
abortController: processAbort,
|
||||||
includePartialMessages: true,
|
includePartialMessages: true,
|
||||||
permissionMode: permissionModeFor(this.permissionMode, this.agentMode),
|
permissionMode: permissionModeFor(this.permissionMode, this.agentMode),
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { privateRuntimeEnvironment } from '../private-runtimes'
|
||||||
import { app, safeStorage } from 'electron'
|
import { app, safeStorage } from 'electron'
|
||||||
import { promises as fs } from 'node:fs'
|
import { promises as fs } from 'node:fs'
|
||||||
import { join, isAbsolute } from 'node:path'
|
import { join, isAbsolute } from 'node:path'
|
||||||
import { createHash, randomUUID } from 'node:crypto'
|
import { createHash, randomUUID } from 'node:crypto'
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
||||||
import type { McpServerInput, McpServerView, McpToolView, McpStatus } from '@shared/capabilities'
|
import type { McpServerInput, McpServerView, McpToolView, McpStatus } from '@shared/capabilities'
|
||||||
@@ -183,12 +184,21 @@ export async function connectMcpServer(id: string): Promise<McpServerView> {
|
|||||||
if (existing?.status === 'connected' && existing.configHash === server.configHash) return view(server)
|
if (existing?.status === 'connected' && existing.configHash === server.configHash) return view(server)
|
||||||
if (existing?.flight && existing.configHash === server.configHash) return existing.flight
|
if (existing?.flight && existing.configHash === server.configHash) return existing.flight
|
||||||
if (existing) await closeRuntime(id)
|
if (existing) await closeRuntime(id)
|
||||||
|
const values = readSecrets(server)
|
||||||
|
const childEnvironment = server.transport === 'stdio'
|
||||||
|
? await privateRuntimeEnvironment({ ...getDefaultEnvironment(), ...values.env }) : undefined
|
||||||
await writes
|
await writes
|
||||||
const current = requireServer(await load(), id)
|
const current = requireServer(await load(), id)
|
||||||
if (current.configHash !== server.configHash || !current.enabled || current.trustedHash !== current.configHash) throw new Error('MCP 配置已变化,请重试')
|
if (current.configHash !== server.configHash || !current.enabled || current.trustedHash !== current.configHash) throw new Error('MCP 配置已变化,请重试')
|
||||||
const values = readSecrets(server), client = new Client({ name: 'YCode', version: '0.1.0' })
|
// Another connect may have completed while private settings / registry were loading.
|
||||||
|
const concurrent = runtimes.get(id)
|
||||||
|
if (concurrent?.configHash === server.configHash) {
|
||||||
|
if (concurrent.status === 'connected') return view(server)
|
||||||
|
if (concurrent.flight) return concurrent.flight
|
||||||
|
}
|
||||||
|
const client = new Client({ name: 'YCode', version: '0.1.0' })
|
||||||
const transport: Transport = server.transport === 'stdio'
|
const transport: Transport = server.transport === 'stdio'
|
||||||
? new StdioClientTransport({ command: server.command!, args: server.args, cwd: server.cwd, env: values.env, stderr: 'ignore' })
|
? new StdioClientTransport({ command: server.command!, args: server.args, cwd: server.cwd, env: childEnvironment, stderr: 'ignore' })
|
||||||
: new StreamableHTTPClientTransport(new URL(server.url!), { requestInit: { headers: values.headers, redirect: 'error' },
|
: new StreamableHTTPClientTransport(new URL(server.url!), { requestInit: { headers: values.headers, redirect: 'error' },
|
||||||
// Keep credentials on the configured origin. GET streams also use this guarded fetch.
|
// Keep credentials on the configured origin. GET streams also use this guarded fetch.
|
||||||
fetch: async (input, init) => {
|
fetch: async (input, init) => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getPrivateRuntimes, updatePrivateRuntimes } from './private-runtimes'
|
||||||
/**
|
/**
|
||||||
* IPC 注册层:渲染进程与主进程之间唯一的契约边界。
|
* IPC 注册层:渲染进程与主进程之间唯一的契约边界。
|
||||||
*
|
*
|
||||||
@@ -422,6 +423,8 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
|||||||
ipcMain.handle('general:get', (_event, options?: { refreshUsage?: boolean }) => generalView(options?.refreshUsage === true))
|
ipcMain.handle('general:get', (_event, options?: { refreshUsage?: boolean }) => generalView(options?.refreshUsage === true))
|
||||||
ipcMain.handle('personalization:get', () => personalization.getPersonalization())
|
ipcMain.handle('personalization:get', () => personalization.getPersonalization())
|
||||||
ipcMain.handle('personalization:update', (_event, patch: PersonalizationPatch) => personalization.updatePersonalization(patch))
|
ipcMain.handle('personalization:update', (_event, patch: PersonalizationPatch) => personalization.updatePersonalization(patch))
|
||||||
|
ipcMain.handle('private-runtimes:get', () => getPrivateRuntimes())
|
||||||
|
ipcMain.handle('private-runtimes:update', (_event, patch) => updatePrivateRuntimes(patch))
|
||||||
ipcMain.handle('agents:get', () => agentSettings.getAgentSettings())
|
ipcMain.handle('agents:get', () => agentSettings.getAgentSettings())
|
||||||
ipcMain.handle('agents:update', (_event, patch: AgentSettingsPatch) => agentSettings.updateAgentSettings(patch))
|
ipcMain.handle('agents:update', (_event, patch: AgentSettingsPatch) => agentSettings.updateAgentSettings(patch))
|
||||||
ipcMain.handle('memory:get', () => memorySettings.getMemorySettings())
|
ipcMain.handle('memory:get', () => memorySettings.getMemorySettings())
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { win32 } from 'node:path'
|
||||||
|
import type { PrivateRuntimeId, PrivateRuntimePatch, PrivateRuntimePreferences, PrivateRuntimesView } from '../shared/private-runtimes'
|
||||||
|
|
||||||
|
export const PRIVATE_RUNTIME_IDS: PrivateRuntimeId[] = ['node', 'python', 'git']
|
||||||
|
export function normalizePrivateRuntimePreferences(value: unknown): PrivateRuntimePreferences {
|
||||||
|
const source = value && typeof value === 'object' ? value as PrivateRuntimePatch : {}
|
||||||
|
return { enabled: source.enabled !== false, tools: {
|
||||||
|
node: source.tools?.node !== false, python: source.tools?.python !== false, git: source.tools?.git !== false
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
export function validatePrivateRuntimePatch(value: unknown): PrivateRuntimePatch {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('运行环境设置无效')
|
||||||
|
const input = value as Record<string, unknown>, patch: PrivateRuntimePatch = {}
|
||||||
|
if (Object.keys(input).some(key => key !== 'enabled' && key !== 'tools')) throw new Error('未知运行环境设置')
|
||||||
|
if ('enabled' in input) {
|
||||||
|
if (typeof input.enabled !== 'boolean') throw new Error('运行环境开关无效')
|
||||||
|
patch.enabled = input.enabled
|
||||||
|
}
|
||||||
|
if ('tools' in input) {
|
||||||
|
if (!input.tools || typeof input.tools !== 'object' || Array.isArray(input.tools)) throw new Error('工具设置无效')
|
||||||
|
patch.tools = {}
|
||||||
|
for (const [key, enabled] of Object.entries(input.tools)) {
|
||||||
|
if (!PRIVATE_RUNTIME_IDS.includes(key as PrivateRuntimeId) || typeof enabled !== 'boolean') throw new Error('工具开关无效')
|
||||||
|
patch.tools[key as PrivateRuntimeId] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return patch
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fixed application-owned layout, never a renderer-supplied executable or PATH. */
|
||||||
|
export function privateRuntimeView(platform: string, root: string, preferences: PrivateRuntimePreferences,
|
||||||
|
isFile: (path: string) => boolean): PrivateRuntimesView {
|
||||||
|
const required: Record<PrivateRuntimeId, string[]> = {
|
||||||
|
node: ['node.exe', 'npm.cmd', 'npx.cmd'],
|
||||||
|
python: ['python.exe', 'Scripts/pip.cmd'],
|
||||||
|
git: ['cmd/git.exe', 'bin/bash.exe']
|
||||||
|
}
|
||||||
|
const supported = platform === 'win32'
|
||||||
|
return { platform, supported, preferences, tools: PRIVATE_RUNTIME_IDS.map(id => {
|
||||||
|
const path = win32.join(root, id)
|
||||||
|
const available = supported && required[id].every(file => isFile(win32.join(path, file)))
|
||||||
|
const enabled = preferences.tools[id]
|
||||||
|
return { id, available, enabled, active: supported && available && preferences.enabled && enabled, path: available ? path : null }
|
||||||
|
}) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure clone: keep callers' credential policy and never mutate process.env or system PATH. */
|
||||||
|
export function applyPrivateRuntimeEnvironment(environment: NodeJS.ProcessEnv, view: PrivateRuntimesView, dataRoot?: string): NodeJS.ProcessEnv {
|
||||||
|
const env = { ...environment }
|
||||||
|
if (!view.supported || !view.preferences.enabled) return env
|
||||||
|
const active = view.tools.filter(tool => tool.active && tool.path)
|
||||||
|
if (!active.length) return env
|
||||||
|
const paths = active.flatMap(tool => tool.id === 'python' ? [tool.path!, win32.join(tool.path!, 'Scripts')]
|
||||||
|
: tool.id === 'git' ? [win32.join(tool.path!, 'cmd'), win32.join(tool.path!, 'bin')] : [tool.path!])
|
||||||
|
if (dataRoot) {
|
||||||
|
const set = (key: string, value: string): void => {
|
||||||
|
for (const name of Object.keys(env)) if (name.toLowerCase() === key.toLowerCase()) delete env[name]
|
||||||
|
env[key] = value
|
||||||
|
}
|
||||||
|
if (active.some(tool => tool.id === 'node')) {
|
||||||
|
const prefix = win32.join(dataRoot, 'node', 'global')
|
||||||
|
set('NPM_CONFIG_PREFIX', prefix)
|
||||||
|
set('NPM_CONFIG_CACHE', win32.join(dataRoot, 'node', 'cache'))
|
||||||
|
paths.push(prefix) // Windows npm global executable shims live directly in prefix.
|
||||||
|
}
|
||||||
|
if (active.some(tool => tool.id === 'python')) {
|
||||||
|
const userBase = win32.join(dataRoot, 'python', 'user')
|
||||||
|
set('PYTHONUSERBASE', userBase)
|
||||||
|
set('PIP_CACHE_DIR', win32.join(dataRoot, 'python', 'cache'))
|
||||||
|
// Bundled CPython 3.14 uses the Windows nt_user scheme. Do not set
|
||||||
|
// PYTHONHOME/PIP_TARGET/PIP_USER: those would interfere with project venvs.
|
||||||
|
paths.push(win32.join(userBase, 'Python314', 'Scripts'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pathKeys = Object.keys(env).filter(key => key.toLowerCase() === 'path')
|
||||||
|
// Windows treats these names case-insensitively; only emit one to avoid Node's ambiguous selection.
|
||||||
|
const key = 'PATH' // Also overrides the MCP SDK's inherited uppercase PATH on its final merge.
|
||||||
|
const original = pathKeys.map(name => env[name]).filter((value): value is string => !!value).join(';')
|
||||||
|
for (const name of pathKeys) delete env[name]
|
||||||
|
const seen = new Set<string>()
|
||||||
|
env[key] = [...paths, ...original.split(';')].filter(part => {
|
||||||
|
if (!part) return false
|
||||||
|
const normalized = part.replace(/\\+$/u, '').toLowerCase()
|
||||||
|
if (seen.has(normalized)) return false
|
||||||
|
seen.add(normalized)
|
||||||
|
return true
|
||||||
|
}).join(';')
|
||||||
|
const git = active.find(tool => tool.id === 'git')
|
||||||
|
if (git) {
|
||||||
|
for (const name of Object.keys(env)) if (name.toLowerCase() === 'claude_code_git_bash_path') delete env[name]
|
||||||
|
env.CLAUDE_CODE_GIT_BASH_PATH = win32.join(git.path!, 'bin', 'bash.exe')
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { statSync, promises as fs } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import type { PrivateRuntimePatch, PrivateRuntimePreferences, PrivateRuntimesView } from '../shared/private-runtimes'
|
||||||
|
import { applyPrivateRuntimeEnvironment, normalizePrivateRuntimePreferences, privateRuntimeView, validatePrivateRuntimePatch } from './private-runtime-environment'
|
||||||
|
|
||||||
|
// Kept separate from gateway config so unrelated preference writes cannot overwrite this policy.
|
||||||
|
function preferencesFile(): string { return join(app.getPath('userData'), 'private-runtimes.json') }
|
||||||
|
async function getStoredPrivateRuntimes(): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
const value = validatePrivateRuntimePatch(JSON.parse(await fs.readFile(preferencesFile(), 'utf8')))
|
||||||
|
if (typeof value.enabled !== 'boolean' || !value.tools
|
||||||
|
|| ['node', 'python', 'git'].some(id => typeof value.tools?.[id as keyof typeof value.tools] !== 'boolean')) {
|
||||||
|
throw new Error('私有运行环境设置文件不完整,请修复 private-runtimes.json')
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function setStoredPrivateRuntimes(value: PrivateRuntimePreferences): Promise<void> {
|
||||||
|
const file = preferencesFile(), temporary = `${file}.tmp`
|
||||||
|
await fs.mkdir(join(file, '..'), { recursive: true })
|
||||||
|
try {
|
||||||
|
await fs.writeFile(temporary, JSON.stringify(value, null, 2), 'utf8')
|
||||||
|
await fs.rename(temporary, file)
|
||||||
|
} finally { await fs.rm(temporary, { force: true }).catch(() => undefined) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPrivateRuntimes(): Promise<PrivateRuntimesView> {
|
||||||
|
const preferences = normalizePrivateRuntimePreferences(await getStoredPrivateRuntimes())
|
||||||
|
const resources = app.isPackaged ? process.resourcesPath : join(app.getAppPath(), 'resources')
|
||||||
|
const root = join(resources, 'runtimes', `win32-${process.arch}`)
|
||||||
|
return privateRuntimeView(process.platform, root, preferences, path => {
|
||||||
|
try { return statSync(path).isFile() } catch { return false }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let writes: Promise<unknown> = Promise.resolve()
|
||||||
|
export async function updatePrivateRuntimes(input: PrivateRuntimePatch): Promise<PrivateRuntimesView> {
|
||||||
|
const patch = validatePrivateRuntimePatch(input)
|
||||||
|
const operation = writes.then(async () => {
|
||||||
|
const current = normalizePrivateRuntimePreferences(await getStoredPrivateRuntimes())
|
||||||
|
await setStoredPrivateRuntimes({ enabled: patch.enabled ?? current.enabled, tools: { ...current.tools, ...patch.tools } })
|
||||||
|
return getPrivateRuntimes()
|
||||||
|
})
|
||||||
|
writes = operation.catch(() => undefined)
|
||||||
|
return operation
|
||||||
|
}
|
||||||
|
export function privateRuntimeEnvironment(environment: Record<string, string>): Promise<Record<string, string>>
|
||||||
|
export function privateRuntimeEnvironment(environment: NodeJS.ProcessEnv): Promise<NodeJS.ProcessEnv>
|
||||||
|
export async function privateRuntimeEnvironment(environment: NodeJS.ProcessEnv): Promise<NodeJS.ProcessEnv> {
|
||||||
|
// Preserve non-Windows startup behavior and avoid needless configuration I/O.
|
||||||
|
if (process.platform !== 'win32') return { ...environment }
|
||||||
|
return applyPrivateRuntimeEnvironment(environment, await getPrivateRuntimes(), join(app.getPath('userData'), 'runtime-data'))
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { privateRuntimeEnvironment } from './private-runtimes'
|
||||||
import type { BrowserWindow } from 'electron'
|
import type { BrowserWindow } from 'electron'
|
||||||
import { promises as fs } from 'node:fs'
|
import { promises as fs } from 'node:fs'
|
||||||
import { isAbsolute, join, resolve } from 'node:path'
|
import { isAbsolute, join, resolve } from 'node:path'
|
||||||
@@ -94,6 +95,7 @@ export async function createTerminal(owner: BrowserWindow, cwd: string, sessionI
|
|||||||
if (typeof cwd !== 'string' || !isAbsolute(cwd)) throw new Error('终端需要有效的绝对工作目录')
|
if (typeof cwd !== 'string' || !isAbsolute(cwd)) throw new Error('终端需要有效的绝对工作目录')
|
||||||
const root = resolve(cwd)
|
const root = resolve(cwd)
|
||||||
if (!(await fs.stat(root).catch(() => null))?.isDirectory()) throw new Error('工作空间文件夹不存在')
|
if (!(await fs.stat(root).catch(() => null))?.isDirectory()) throw new Error('工作空间文件夹不存在')
|
||||||
|
const childEnvironment = terminalEnvironment(await privateRuntimeEnvironment(terminalEnvironment()))
|
||||||
validOwner(owner)
|
validOwner(owner)
|
||||||
const existing = [...terminals.values()]
|
const existing = [...terminals.values()]
|
||||||
const active = existing.filter(entry => !entry.view.exited)
|
const active = existing.filter(entry => !entry.view.exited)
|
||||||
@@ -112,7 +114,7 @@ export async function createTerminal(owner: BrowserWindow, cwd: string, sessionI
|
|||||||
? join(process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') : '/bin/bash'
|
? join(process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') : '/bin/bash'
|
||||||
let pty: IPty
|
let pty: IPty
|
||||||
try { pty = spawn(shell, process.platform === 'win32' ? ['-NoLogo', '-NoProfile'] : ['--noprofile', '--norc'], {
|
try { pty = spawn(shell, process.platform === 'win32' ? ['-NoLogo', '-NoProfile'] : ['--noprofile', '--norc'], {
|
||||||
name: 'xterm-256color', cols: columns, rows: lines, cwd: root, env: terminalEnvironment(),
|
name: 'xterm-256color', cols: columns, rows: lines, cwd: root, env: childEnvironment,
|
||||||
...(process.platform === 'win32' ? { useConpty: true, useConptyDll: true } : { encoding: 'utf8' })
|
...(process.platform === 'win32' ? { useConpty: true, useConptyDll: true } : { encoding: 'utf8' })
|
||||||
}) } catch { throw new Error('无法启动本地终端,请检查 PowerShell 和系统终端支持') }
|
}) } catch { throw new Error('无法启动本地终端,请检查 PowerShell 和系统终端支持') }
|
||||||
const view: TerminalHostView = { id: randomUUID(), sessionId: ownerSession, cwd: root, title: 'PowerShell', shell,
|
const view: TerminalHostView = { id: randomUUID(), sessionId: ownerSession, cwd: root, title: 'PowerShell', shell,
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import type { DownloadPayload, ResourceRange } from '@shared/resources'
|
|||||||
import type { InteractionResponse } from '@shared/conversation'
|
import type { InteractionResponse } from '@shared/conversation'
|
||||||
|
|
||||||
const api: YCodeApi = {
|
const api: YCodeApi = {
|
||||||
|
privateRuntimes: {
|
||||||
|
get: () => ipcRenderer.invoke('private-runtimes:get'),
|
||||||
|
update: patch => ipcRenderer.invoke('private-runtimes:update', patch)
|
||||||
|
},
|
||||||
updates: {
|
updates: {
|
||||||
getState: () => ipcRenderer.invoke('updates:get-state'),
|
getState: () => ipcRenderer.invoke('updates:get-state'),
|
||||||
check: () => ipcRenderer.invoke('updates:check'),
|
check: () => ipcRenderer.invoke('updates:check'),
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { PrivateRuntimeId, PrivateRuntimePatch, PrivateRuntimesView } from '@shared/private-runtimes'
|
||||||
|
import { Switch } from '../ui/Switch'
|
||||||
|
import { Button } from '../ui/Button'
|
||||||
|
import './private-runtimes-settings.css'
|
||||||
|
|
||||||
|
const TOOLS: { id: PrivateRuntimeId; name: string; description: string; symbol: string }[] = [
|
||||||
|
{ id: 'node', name: 'Node.js', description: '基于 Chrome V8 引擎的 JavaScript 运行时,用于服务端开发。', symbol: 'JS' },
|
||||||
|
{ id: 'python', name: 'Python', description: '通用编程语言,适用于脚本编写、自动化和数据处理。', symbol: 'Py' },
|
||||||
|
{ id: 'git', name: 'Git Bash', description: '在 Windows 上提供 Git 和 Bash Shell 的类 Unix 命令行环境。', symbol: '⑂' }
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Only the user-selected runtime card; no unrelated security controls. */
|
||||||
|
export default function PrivateRuntimesSettingsPanel(): React.JSX.Element {
|
||||||
|
const [view, setView] = useState<PrivateRuntimesView | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const mounted = useRef(false)
|
||||||
|
const busy = useRef(false)
|
||||||
|
const generation = useRef(0)
|
||||||
|
|
||||||
|
const refresh = async (): Promise<void> => {
|
||||||
|
if (busy.current) return
|
||||||
|
const request = ++generation.current
|
||||||
|
setLoading(true); setError('')
|
||||||
|
try {
|
||||||
|
const next = await window.ycode.privateRuntimes.get()
|
||||||
|
if (mounted.current && request === generation.current) setView(next)
|
||||||
|
} catch (reason) {
|
||||||
|
if (mounted.current && request === generation.current) setError(`无法读取内置运行时:${String((reason as Error)?.message ?? reason)}`)
|
||||||
|
} finally {
|
||||||
|
if (mounted.current && request === generation.current) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true
|
||||||
|
void refresh()
|
||||||
|
return () => { mounted.current = false; ++generation.current }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const update = async (patch: PrivateRuntimePatch): Promise<void> => {
|
||||||
|
if (busy.current || loading || !view?.supported) return
|
||||||
|
busy.current = true; setSaving(true); setError('')
|
||||||
|
try {
|
||||||
|
// The checkmarks represent persisted choices, never optimistic success.
|
||||||
|
const next = await window.ycode.privateRuntimes.update(patch)
|
||||||
|
if (mounted.current) setView(next)
|
||||||
|
} catch (reason) {
|
||||||
|
if (mounted.current) setError(`保存失败,请重新读取确认当前状态:${String((reason as Error)?.message ?? reason)}`)
|
||||||
|
} finally {
|
||||||
|
busy.current = false
|
||||||
|
if (mounted.current) setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const disabled = loading || saving || !view?.supported
|
||||||
|
return <div className="private-runtimes-settings">
|
||||||
|
<section className="private-runtimes-settings__card" aria-label="内置运行时" aria-busy={loading || saving}>
|
||||||
|
<div className="private-runtimes-settings__row private-runtimes-settings__header">
|
||||||
|
<div><h2>内置运行时</h2><p>允许使用随包提供的运行时工具。</p></div>
|
||||||
|
<Switch size="lg" aria-label="启用内置运行时" checked={view?.preferences.enabled ?? false} disabled={disabled} onCheckedChange={enabled => void update({ enabled })} />
|
||||||
|
</div>
|
||||||
|
{TOOLS.map(tool => {
|
||||||
|
const status = view?.tools.find(item => item.id === tool.id)
|
||||||
|
const state = !view ? (loading ? '正在检测' : '无法读取状态') : !view.supported ? '当前平台不支持' : !status?.available ? '未随包提供或文件不完整' : status.active ? '已启用(新进程生效)' : view.preferences.enabled ? '已停用' : '总开关已关闭(保留单项选择)'
|
||||||
|
return <div className="private-runtimes-settings__row" key={tool.id} data-runtime={tool.id}>
|
||||||
|
<div className="private-runtimes-settings__copy">
|
||||||
|
<h3><span className={`private-runtimes-settings__icon private-runtimes-settings__icon--${tool.id}`} aria-hidden="true">{tool.symbol}</span>{tool.name}</h3>
|
||||||
|
<p>{tool.description}</p>
|
||||||
|
<small title={status?.path ?? undefined}>{state}</small>
|
||||||
|
</div>
|
||||||
|
<Switch size="lg" aria-label={`启用 ${tool.name}`} checked={view?.preferences.tools[tool.id] ?? false} disabled={disabled || !view?.preferences.enabled || !status?.available} onCheckedChange={enabled => void update({ tools: { [tool.id]: enabled } })} />
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
{loading && <p role="status">正在读取内置运行时…</p>}
|
||||||
|
{saving && <p role="status">正在保存…</p>}
|
||||||
|
{error && <div role="alert"><p>{error}</p><Button size="sm" disabled={loading || saving} onClick={() => void refresh()}>重新读取</Button></div>}
|
||||||
|
{view && !view.supported && <p role="status">内置运行时仅支持 Windows x64;当前平台不使用这些随包工具。</p>}
|
||||||
|
<p className="private-runtimes-settings__note">更改仅对之后启动的进程生效,请重新打开终端或启动新会话。关闭后不再注入随包工具,系统已安装的工具仍可能可用;不会修改系统 PATH 或应用权限。</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ const ShortcutsSettingsPanel = loadSettingsPanel(() => import('./ShortcutsSettin
|
|||||||
const PersonalizationSettingsPanel = loadSettingsPanel(() => import('./PersonalizationSettingsPanel'))
|
const PersonalizationSettingsPanel = loadSettingsPanel(() => import('./PersonalizationSettingsPanel'))
|
||||||
const MemorySettingsPanel = loadSettingsPanel(() => import('./MemorySettingsPanel'))
|
const MemorySettingsPanel = loadSettingsPanel(() => import('./MemorySettingsPanel'))
|
||||||
const AboutHelpSettingsPanel = loadSettingsPanel(() => import('./AboutHelpSettingsPanel'))
|
const AboutHelpSettingsPanel = loadSettingsPanel(() => import('./AboutHelpSettingsPanel'))
|
||||||
|
const PrivateRuntimesSettingsPanel = loadSettingsPanel(() => import('./PrivateRuntimesSettingsPanel'))
|
||||||
const AgentsSettingsPanel = loadSettingsPanel(() => import('./AgentsSettingsPanel'))
|
const AgentsSettingsPanel = loadSettingsPanel(() => import('./AgentsSettingsPanel'))
|
||||||
|
|
||||||
interface SettingsNavigationRequest { section: string; action?: 'new'; requestId: number }
|
interface SettingsNavigationRequest { section: string; action?: 'new'; requestId: number }
|
||||||
@@ -77,7 +78,7 @@ export default function SettingsDialog({ theme, onThemeChange, onClose, nativeWi
|
|||||||
return () => window.removeEventListener('keydown', escape)
|
return () => window.removeEventListener('keydown', escape)
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
const modal = <section className={`settings-modal${nativeWindow ? ' settings-modal--window' : ''}`} role="dialog" aria-label="设置" aria-modal={!nativeWindow}>
|
const modal = <section className={`settings-modal${nativeWindow ? ' settings-modal--window' : ''}`} role="dialog" aria-label="设置" aria-modal={!nativeWindow}>
|
||||||
<div className="settings-modal__nav"><SourceSettingsNavigation activeTab={section} supportedTabs={['settings', 'models', 'appearance', 'keyboardShortcuts', 'personalization', 'memory', 'extensions', 'helpFeedback', 'getHelp']} visibleTabs={VISIBLE_SETTINGS_TABS}
|
<div className="settings-modal__nav"><SourceSettingsNavigation activeTab={section} supportedTabs={['settings', 'models', 'appearance', 'keyboardShortcuts', 'personalization', 'memory', 'extensions', 'securityCenter', 'helpFeedback', 'getHelp']} visibleTabs={VISIBLE_SETTINGS_TABS}
|
||||||
onTabChange={(id) => { setError(''); setSection(id === 'models' ? 'model' : id) }} onUnavailable={() => setError('该设置尚未接入')} /></div>
|
onTabChange={(id) => { setError(''); setSection(id === 'models' ? 'model' : id) }} onUnavailable={() => setError('该设置尚未接入')} /></div>
|
||||||
<div className="settings-modal__content"><div className="settings-modal__panel">
|
<div className="settings-modal__content"><div className="settings-modal__panel">
|
||||||
{error && <p role="alert">{error}</p>}
|
{error && <p role="alert">{error}</p>}
|
||||||
@@ -89,6 +90,7 @@ export default function SettingsDialog({ theme, onThemeChange, onClose, nativeWi
|
|||||||
{section === 'personalization' && <PersonalizationSettingsPanel />}
|
{section === 'personalization' && <PersonalizationSettingsPanel />}
|
||||||
{section === 'memory' && <MemorySettingsPanel />}
|
{section === 'memory' && <MemorySettingsPanel />}
|
||||||
{(section === 'helpFeedback' || section === 'getHelp') && <AboutHelpSettingsPanel section={section} />}
|
{(section === 'helpFeedback' || section === 'getHelp') && <AboutHelpSettingsPanel section={section} />}
|
||||||
|
{section === 'securityCenter' && <PrivateRuntimesSettingsPanel />}
|
||||||
{section === 'extensions' && <AgentsSettingsPanel />}
|
{section === 'extensions' && <AgentsSettingsPanel />}
|
||||||
{section === 'about' && <div className="settings-window-panel"><h2>YCode</h2><p>{settings.appInfo?.version}</p></div>}
|
{section === 'about' && <div className="settings-window-panel"><h2>YCode</h2><p>{settings.appInfo?.version}</p></div>}
|
||||||
</div></div>
|
</div></div>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* User-provided highlighted runtime card; scoped styling, not a recovered security page. */
|
||||||
|
.private-runtimes-settings { color: var(--yc-text-primary); font-size: var(--yc-text-body-size); }
|
||||||
|
.private-runtimes-settings__card { background: var(--yc-bg-primary); border-radius: var(--yc-radius-xl); padding: var(--yc-space-5); }
|
||||||
|
.private-runtimes-settings__row { display: flex; align-items: center; justify-content: space-between; gap: var(--yc-space-5); padding-top: var(--yc-space-5); }
|
||||||
|
.private-runtimes-settings__header { padding-top: 0; padding-bottom: var(--yc-space-5); border-bottom: var(--yc-border-width) solid var(--yc-border-secondary); }
|
||||||
|
.private-runtimes-settings h2, .private-runtimes-settings h3, .private-runtimes-settings p { margin: 0; }
|
||||||
|
.private-runtimes-settings h2 { font-size: var(--yc-font-size-6); font-weight: var(--yc-font-weight-500); line-height: var(--yc-line-height-6); }
|
||||||
|
.private-runtimes-settings h3 { display: flex; align-items: center; gap: var(--yc-space-3); font-size: var(--yc-font-size-5); font-weight: var(--yc-font-weight-500); line-height: var(--yc-line-height-5-2); }
|
||||||
|
.private-runtimes-settings p { color: var(--yc-text-secondary); line-height: var(--yc-line-height-5-2); }
|
||||||
|
.private-runtimes-settings small { color: var(--yc-text-tertiary); font-size: var(--yc-font-size-3); line-height: var(--yc-line-height-4-2); }
|
||||||
|
.private-runtimes-settings__copy { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.private-runtimes-settings .yc-switch { flex-shrink: 0; }
|
||||||
|
.private-runtimes-settings__icon { display: inline-flex; align-items: center; justify-content: center; width: var(--yc-size-4); height: var(--yc-size-4); font-size: var(--yc-font-size-1); font-weight: var(--yc-font-weight-600); color: var(--yc-status-success); }
|
||||||
|
.private-runtimes-settings__icon--python { color: var(--yc-status-info); }
|
||||||
|
.private-runtimes-settings__icon--git { color: var(--yc-status-warning); font-size: var(--yc-font-size-7); }
|
||||||
|
.private-runtimes-settings > p, .private-runtimes-settings > [role=alert] { margin: var(--yc-space-4) var(--yc-space-5) 0; }
|
||||||
|
.private-runtimes-settings [role=alert] p { color: var(--yc-status-error); }
|
||||||
|
.private-runtimes-settings .private-runtimes-settings__note { font-size: var(--yc-font-size-3); line-height: var(--yc-line-height-5); }
|
||||||
@@ -110,6 +110,7 @@ export interface WorkspaceAttachResult extends WorkspaceRegistryView {
|
|||||||
|
|
||||||
/** preload 暴露给渲染进程的 API 形状 */
|
/** preload 暴露给渲染进程的 API 形状 */
|
||||||
export interface YCodeApi {
|
export interface YCodeApi {
|
||||||
|
privateRuntimes: import('./private-runtimes').PrivateRuntimesApi
|
||||||
updates: UpdatesApi
|
updates: UpdatesApi
|
||||||
automation: AutomationApi
|
automation: AutomationApi
|
||||||
library: LibraryApi
|
library: LibraryApi
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/** Private Windows tools apply only to newly created YCode child processes. */
|
||||||
|
export type PrivateRuntimeId = 'node' | 'python' | 'git'
|
||||||
|
export interface PrivateRuntimePreferences {
|
||||||
|
enabled: boolean
|
||||||
|
tools: Record<PrivateRuntimeId, boolean>
|
||||||
|
}
|
||||||
|
export interface PrivateRuntimePatch {
|
||||||
|
enabled?: boolean
|
||||||
|
tools?: Partial<Record<PrivateRuntimeId, boolean>>
|
||||||
|
}
|
||||||
|
export interface PrivateRuntimeStatus {
|
||||||
|
id: PrivateRuntimeId
|
||||||
|
available: boolean
|
||||||
|
enabled: boolean
|
||||||
|
active: boolean
|
||||||
|
path: string | null
|
||||||
|
}
|
||||||
|
export interface PrivateRuntimesView {
|
||||||
|
platform: string
|
||||||
|
supported: boolean
|
||||||
|
preferences: PrivateRuntimePreferences
|
||||||
|
tools: PrivateRuntimeStatus[]
|
||||||
|
}
|
||||||
|
export interface PrivateRuntimesApi {
|
||||||
|
get(): Promise<PrivateRuntimesView>
|
||||||
|
update(patch: PrivateRuntimePatch): Promise<PrivateRuntimesView>
|
||||||
|
}
|
||||||
Reference in new issue
Block a user