From bef87520c263890f0ddd64a12d9c6a5104844daf Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 16:31:24 +0900 Subject: [PATCH] feat: bundle private Windows runtimes with settings switches --- .gitignore | 5 + docs/Windows内置工具链.md | 65 +++++++++ package-lock.json | 8 ++ package.json | 23 ++- resources/runtimes/THIRD-PARTY-NOTICES.md | 9 ++ resources/runtimes/manifest.json | 74 ++++++++++ scripts/README.md | 4 + scripts/prepare-runtime-bundle.mjs | 54 +++++++ scripts/private-runtimes-regression.cjs | 135 ++++++++++++++++++ scripts/runtime-before-pack.cjs | 7 + scripts/runtime-bundle.mjs | 43 ++++++ scripts/runtime-bundle.test.mjs | 40 ++++++ scripts/runtime-transform.mjs | 33 +++++ scripts/smoke-runtime-bundle.mjs | 39 +++++ scripts/test-private-runtimes-dom.mjs | 57 ++++++++ scripts/verify-runtime-bundle.mjs | 2 + src/main/agent/claude/claude-kernel.ts | 4 +- src/main/capabilities/mcp.ts | 16 ++- src/main/ipc.ts | 3 + src/main/private-runtime-environment.ts | 94 ++++++++++++ src/main/private-runtimes.ts | 57 ++++++++ src/main/terminal-host.ts | 4 +- src/preload/index.ts | 4 + .../PrivateRuntimesSettingsPanel.tsx | 82 +++++++++++ .../src/components/SettingsDialog.tsx | 4 +- .../components/private-runtimes-settings.css | 18 +++ src/shared/ipc.ts | 1 + src/shared/private-runtimes.ts | 27 ++++ 28 files changed, 902 insertions(+), 10 deletions(-) create mode 100644 docs/Windows内置工具链.md create mode 100644 resources/runtimes/THIRD-PARTY-NOTICES.md create mode 100644 resources/runtimes/manifest.json create mode 100644 scripts/prepare-runtime-bundle.mjs create mode 100644 scripts/private-runtimes-regression.cjs create mode 100644 scripts/runtime-before-pack.cjs create mode 100644 scripts/runtime-bundle.mjs create mode 100644 scripts/runtime-bundle.test.mjs create mode 100644 scripts/runtime-transform.mjs create mode 100644 scripts/smoke-runtime-bundle.mjs create mode 100644 scripts/test-private-runtimes-dom.mjs create mode 100644 scripts/verify-runtime-bundle.mjs create mode 100644 src/main/private-runtime-environment.ts create mode 100644 src/main/private-runtimes.ts create mode 100644 src/renderer/src/components/PrivateRuntimesSettingsPanel.tsx create mode 100644 src/renderer/src/components/private-runtimes-settings.css create mode 100644 src/shared/private-runtimes.ts diff --git a/.gitignore b/.gitignore index 704cb8f..0b7bdd9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,8 @@ release/ .env.local .DS_Store *.local + +# Reproducibly downloaded Windows toolchains; keep manifest and notices tracked. +resources/runtimes/win32-x64/ +resources/runtimes/win32-x64.previous/ +resources/runtimes/.prepare-*/ diff --git a/docs/Windows内置工具链.md b/docs/Windows内置工具链.md new file mode 100644 index 0000000..9b00feb --- /dev/null +++ b/docs/Windows内置工具链.md @@ -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 +"\python\python.exe" -m venv ".venv" +".venv\Scripts\python.exe" -m pip install +``` + +虚拟环境是用户工作区数据,不能作为可搬移运行时打包;移动 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 `。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 二进制执行与安装包验收。 diff --git a/package-lock.json b/package-lock.json index cbf21ab..88d8995 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "@types/react-dom": "^19.3.0", "@types/svg-pan-zoom": "^3.3.0", "@vitejs/plugin-react": "^6.1.2", + "7zip-bin": "5.2.0", "electron": "^37.10.3", "electron-builder": "^26.15.3", "electron-vite": "^5.0.0", @@ -3214,6 +3215,13 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-4.0.0.tgz", diff --git a/package.json b/package.json index 60beda7..9ac097a 100644 --- a/package.json +++ b/package.json @@ -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", "shot": "node scripts/shot.mjs", "icon:build": "node scripts/gen-icon.mjs", - "dist": "npm run build && electron-builder --win dir", - "dist:installer": "npm run build && electron-builder --win nsis", + "dist": "npm run runtimes:prepare && npm run build && electron-builder --win dir --x64", + "dist:installer": "npm run runtimes:prepare && npm run build && electron-builder --win nsis --x64", "release": "node scripts/updates/release.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", @@ -95,7 +95,11 @@ "test:fold-summary": "node scripts/rea/recovered-fold-summary-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: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": { "@anthropic-ai/claude-agent-sdk": "^0.3.304", @@ -138,6 +142,7 @@ "@types/react-dom": "^19.3.0", "@types/svg-pan-zoom": "^3.3.0", "@vitejs/plugin-react": "^6.1.2", + "7zip-bin": "5.2.0", "electron": "^37.10.3", "electron-builder": "^26.15.3", "electron-vite": "^5.0.0", @@ -192,6 +197,15 @@ { "from": "resources/tray.png", "to": "tray.png" + }, + { + "from": "resources/runtimes", + "to": "runtimes", + "filter": [ + "manifest.json", + "THIRD-PARTY-NOTICES.md", + "win32-x64/**/*" + ] } ], "win": { @@ -210,6 +224,7 @@ "createDesktopShortcut": true, "createStartMenuShortcut": true, "shortcutName": "YCode" - } + }, + "beforePack": "./scripts/runtime-before-pack.cjs" } } diff --git a/resources/runtimes/THIRD-PARTY-NOTICES.md b/resources/runtimes/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000..86db454 --- /dev/null +++ b/resources/runtimes/THIRD-PARTY-NOTICES.md @@ -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. diff --git a/resources/runtimes/manifest.json b/resources/runtimes/manifest.json new file mode 100644 index 0000000..28bbc83 --- /dev/null +++ b/resources/runtimes/manifest.json @@ -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" + } + ] +} diff --git a/scripts/README.md b/scripts/README.md index d4a0e2c..8cc8127 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -62,3 +62,7 @@ T01–T03 正式入口约定:模式标签通过 Node 构建夹具,再启动 3. 必要断言不能为变绿删除;失败区分产品缺陷与环境缺失。 4. 删除前检查 npm 入口、import/require、runner 动态文件列表、夹具、恢复链及当前文档;仅历史记录引用不等于运行依赖,但须给恢复路径。 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 覆盖。 diff --git a/scripts/prepare-runtime-bundle.mjs b/scripts/prepare-runtime-bundle.mjs new file mode 100644 index 0000000..e5bc332 --- /dev/null +++ b/scripts/prepare-runtime-bundle.mjs @@ -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 }) +} diff --git a/scripts/private-runtimes-regression.cjs b/scripts/private-runtimes-regression.cjs new file mode 100644 index 0000000..93099f7 --- /dev/null +++ b/scripts/private-runtimes-regression.cjs @@ -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 }) diff --git a/scripts/runtime-before-pack.cjs b/scripts/runtime-before-pack.cjs new file mode 100644 index 0000000..279cb56 --- /dev/null +++ b/scripts/runtime-before-pack.cjs @@ -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() +} diff --git a/scripts/runtime-bundle.mjs b/scripts/runtime-bundle.mjs new file mode 100644 index 0000000..6a92261 --- /dev/null +++ b/scripts/runtime-bundle.mjs @@ -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})`) + } +} diff --git a/scripts/runtime-bundle.test.mjs b/scripts/runtime-bundle.test.mjs new file mode 100644 index 0000000..5fde0ee --- /dev/null +++ b/scripts/runtime-bundle.test.mjs @@ -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/) +}) diff --git a/scripts/runtime-transform.mjs b/scripts/runtime-transform.mjs new file mode 100644 index 0000000..ea16805 --- /dev/null +++ b/scripts/runtime-transform.mjs @@ -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. + } +} diff --git a/scripts/smoke-runtime-bundle.mjs b/scripts/smoke-runtime-bundle.mjs new file mode 100644 index 0000000..a11d98f --- /dev/null +++ b/scripts/smoke-runtime-bundle.mjs @@ -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 }) } diff --git a/scripts/test-private-runtimes-dom.mjs b/scripts/test-private-runtimes-dom.mjs new file mode 100644 index 0000000..41965b2 --- /dev/null +++ b/scripts/test-private-runtimes-dom.mjs @@ -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()};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('
',{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()} diff --git a/scripts/verify-runtime-bundle.mjs b/scripts/verify-runtime-bundle.mjs new file mode 100644 index 0000000..8bbd7a7 --- /dev/null +++ b/scripts/verify-runtime-bundle.mjs @@ -0,0 +1,2 @@ +import { verifyRuntimeBundle } from './runtime-bundle.mjs' +await verifyRuntimeBundle(process.argv[2]) diff --git a/src/main/agent/claude/claude-kernel.ts b/src/main/agent/claude/claude-kernel.ts index 9d5f02d..5591406 100644 --- a/src/main/agent/claude/claude-kernel.ts +++ b/src/main/agent/claude/claude-kernel.ts @@ -1,3 +1,4 @@ +import { privateRuntimeEnvironment } from '../../private-runtimes' import { app } from 'electron' import { existsSync, mkdirSync } from 'node:fs' import { join } from 'node:path' @@ -362,6 +363,7 @@ export class ClaudeKernel { } async ensureStarted(): Promise { + const childEnvironment = await privateRuntimeEnvironment(modelEnvironment(this.options.model)) await this.permissionUpdates await this.draining if (this.policyError) throw this.policyError @@ -384,7 +386,7 @@ export class ClaudeKernel { cwd: this.options.workspaceRoot, model: this.options.model.config.model, pathToClaudeCodeExecutable: executable, - env: modelEnvironment(this.options.model), + env: childEnvironment, abortController: processAbort, includePartialMessages: true, permissionMode: permissionModeFor(this.permissionMode, this.agentMode), diff --git a/src/main/capabilities/mcp.ts b/src/main/capabilities/mcp.ts index 9cde315..6709422 100644 --- a/src/main/capabilities/mcp.ts +++ b/src/main/capabilities/mcp.ts @@ -1,9 +1,10 @@ +import { privateRuntimeEnvironment } from '../private-runtimes' import { app, safeStorage } from 'electron' import { promises as fs } from 'node:fs' import { join, isAbsolute } from 'node:path' import { createHash, randomUUID } from 'node:crypto' 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 type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import type { McpServerInput, McpServerView, McpToolView, McpStatus } from '@shared/capabilities' @@ -183,12 +184,21 @@ export async function connectMcpServer(id: string): Promise { if (existing?.status === 'connected' && existing.configHash === server.configHash) return view(server) if (existing?.flight && existing.configHash === server.configHash) return existing.flight if (existing) await closeRuntime(id) + const values = readSecrets(server) + const childEnvironment = server.transport === 'stdio' + ? await privateRuntimeEnvironment({ ...getDefaultEnvironment(), ...values.env }) : undefined await writes const current = requireServer(await load(), id) 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' - ? 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' }, // Keep credentials on the configured origin. GET streams also use this guarded fetch. fetch: async (input, init) => { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 668fb37..92fc0ce 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,3 +1,4 @@ +import { getPrivateRuntimes, updatePrivateRuntimes } from './private-runtimes' /** * 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('personalization:get', () => personalization.getPersonalization()) 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:update', (_event, patch: AgentSettingsPatch) => agentSettings.updateAgentSettings(patch)) ipcMain.handle('memory:get', () => memorySettings.getMemorySettings()) diff --git a/src/main/private-runtime-environment.ts b/src/main/private-runtime-environment.ts new file mode 100644 index 0000000..4344785 --- /dev/null +++ b/src/main/private-runtime-environment.ts @@ -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, 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 = { + 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() + 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 +} diff --git a/src/main/private-runtimes.ts b/src/main/private-runtimes.ts new file mode 100644 index 0000000..ca54176 --- /dev/null +++ b/src/main/private-runtimes.ts @@ -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 { + 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 { + 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 { + 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 = Promise.resolve() +export async function updatePrivateRuntimes(input: PrivateRuntimePatch): Promise { + 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): Promise> +export function privateRuntimeEnvironment(environment: NodeJS.ProcessEnv): Promise +export async function privateRuntimeEnvironment(environment: NodeJS.ProcessEnv): Promise { + // 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')) +} diff --git a/src/main/terminal-host.ts b/src/main/terminal-host.ts index 9486c26..f966ca8 100644 --- a/src/main/terminal-host.ts +++ b/src/main/terminal-host.ts @@ -1,3 +1,4 @@ +import { privateRuntimeEnvironment } from './private-runtimes' import type { BrowserWindow } from 'electron' import { promises as fs } from 'node:fs' 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('终端需要有效的绝对工作目录') const root = resolve(cwd) if (!(await fs.stat(root).catch(() => null))?.isDirectory()) throw new Error('工作空间文件夹不存在') + const childEnvironment = terminalEnvironment(await privateRuntimeEnvironment(terminalEnvironment())) validOwner(owner) const existing = [...terminals.values()] 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' let pty: IPty 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' }) }) } catch { throw new Error('无法启动本地终端,请检查 PowerShell 和系统终端支持') } const view: TerminalHostView = { id: randomUUID(), sessionId: ownerSession, cwd: root, title: 'PowerShell', shell, diff --git a/src/preload/index.ts b/src/preload/index.ts index 1901189..3f6d507 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,10 @@ import type { DownloadPayload, ResourceRange } from '@shared/resources' import type { InteractionResponse } from '@shared/conversation' const api: YCodeApi = { + privateRuntimes: { + get: () => ipcRenderer.invoke('private-runtimes:get'), + update: patch => ipcRenderer.invoke('private-runtimes:update', patch) + }, updates: { getState: () => ipcRenderer.invoke('updates:get-state'), check: () => ipcRenderer.invoke('updates:check'), diff --git a/src/renderer/src/components/PrivateRuntimesSettingsPanel.tsx b/src/renderer/src/components/PrivateRuntimesSettingsPanel.tsx new file mode 100644 index 0000000..b990ed1 --- /dev/null +++ b/src/renderer/src/components/PrivateRuntimesSettingsPanel.tsx @@ -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(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 => { + 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 => { + 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
+
+
+

内置运行时

允许使用随包提供的运行时工具。

+ void update({ enabled })} /> +
+ {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
+
+

{tool.name}

+

{tool.description}

+ {state} +
+ void update({ tools: { [tool.id]: enabled } })} /> +
+ })} +
+ {loading &&

正在读取内置运行时…

} + {saving &&

正在保存…

} + {error &&

{error}

} + {view && !view.supported &&

内置运行时仅支持 Windows x64;当前平台不使用这些随包工具。

} +

更改仅对之后启动的进程生效,请重新打开终端或启动新会话。关闭后不再注入随包工具,系统已安装的工具仍可能可用;不会修改系统 PATH 或应用权限。

+
+} diff --git a/src/renderer/src/components/SettingsDialog.tsx b/src/renderer/src/components/SettingsDialog.tsx index 1568329..9a37440 100644 --- a/src/renderer/src/components/SettingsDialog.tsx +++ b/src/renderer/src/components/SettingsDialog.tsx @@ -24,6 +24,7 @@ const ShortcutsSettingsPanel = loadSettingsPanel(() => import('./ShortcutsSettin const PersonalizationSettingsPanel = loadSettingsPanel(() => import('./PersonalizationSettingsPanel')) const MemorySettingsPanel = loadSettingsPanel(() => import('./MemorySettingsPanel')) const AboutHelpSettingsPanel = loadSettingsPanel(() => import('./AboutHelpSettingsPanel')) +const PrivateRuntimesSettingsPanel = loadSettingsPanel(() => import('./PrivateRuntimesSettingsPanel')) const AgentsSettingsPanel = loadSettingsPanel(() => import('./AgentsSettingsPanel')) 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) }, [onClose]) const modal =
-
{ setError(''); setSection(id === 'models' ? 'model' : id) }} onUnavailable={() => setError('该设置尚未接入')} />
{error &&

{error}

} @@ -89,6 +90,7 @@ export default function SettingsDialog({ theme, onThemeChange, onClose, nativeWi {section === 'personalization' && } {section === 'memory' && } {(section === 'helpFeedback' || section === 'getHelp') && } + {section === 'securityCenter' && } {section === 'extensions' && } {section === 'about' &&

YCode

{settings.appInfo?.version}

}
diff --git a/src/renderer/src/components/private-runtimes-settings.css b/src/renderer/src/components/private-runtimes-settings.css new file mode 100644 index 0000000..4d7fe86 --- /dev/null +++ b/src/renderer/src/components/private-runtimes-settings.css @@ -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); } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index d34695c..0ac7d34 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -110,6 +110,7 @@ export interface WorkspaceAttachResult extends WorkspaceRegistryView { /** preload 暴露给渲染进程的 API 形状 */ export interface YCodeApi { + privateRuntimes: import('./private-runtimes').PrivateRuntimesApi updates: UpdatesApi automation: AutomationApi library: LibraryApi diff --git a/src/shared/private-runtimes.ts b/src/shared/private-runtimes.ts new file mode 100644 index 0000000..6da497a --- /dev/null +++ b/src/shared/private-runtimes.ts @@ -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 +} +export interface PrivateRuntimePatch { + enabled?: boolean + tools?: Partial> +} +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 + update(patch: PrivateRuntimePatch): Promise +}