diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c59b16..ca25061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ ### 改进 (Improvements) +- **OpenClaw 7.1 安装链路加固** — npm 安装会在停止 Gateway 和清理旧文件前按目标版本校验 Node.js;无系统 Node.js 时仍可选择自带运行时的 standalone 安装 +- **standalone 升级原子切换** — 下载归档必须通过 SHA-256 和目标 CLI 版本校验,再通过同盘 staging/backup 切换;激活失败自动恢复原安装 +- **standalone 双源容灾** — 固定稳定版从 GitHub 下载时不再依赖 CDN `latest.json`,CDN 清单故障不会阻断 GitHub fallback +- **Web 自定义域名兼容** — 自动配对会把浏览器当前 Origin 增量写入 Gateway 白名单,支持 HTTPS 域名和自定义端口部署 + - **Hermes 安装过程实时可见** — 安装/升级输出改为逐行实时显示(原先要等安装进程结束才出现日志),长时间安装不再只有转圈 - **Hermes 安装镜像一键配置** — 安装步骤新增「网络与镜像」设置:PyPI 镜像(清华 / 阿里云 / 自定义)与 Git 镜像前缀可直接在向导内选择并保存,国内网络安装成功率显著提升 - **安装失败提示可操作** — 依赖下载失败(PyPI 超时 / SSL / 代理问题)会给出明确的镜像与代理建议,不再只显示一句"安装失败" diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 4e68929..d3dcaf2 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -837,6 +837,15 @@ function ensureNodeRuntimeCompatibleWeb() { } } +function ensureTargetNodeRuntimeCompatibleForNpm(version) { + const requirement = fallbackOpenclawNodeRequirement(version) + if (!requirement) return + const current = process.version + if (!nodeVersionSatisfiesRequirement(current, requirement)) { + throw new Error(`无法通过 npm 安装 OpenClaw ${version}:当前 Node.js ${current},目标版本要求 ${requirement}。请先升级 Node.js,或选择自带 Node.js 的 standalone 安装。`) + } +} + function readWhereWhichOpenclawCandidates() { try { const cmd = isWindows ? 'where openclaw' : 'which -a openclaw 2>/dev/null' @@ -1470,6 +1479,46 @@ function standaloneInstallDir() { return path.join(os.homedir(), '.openclaw-bin') } +async function verifyStandaloneArchiveChecksum(downloadUrl, buffer) { + const checksumResp = await globalThis.fetch(`${downloadUrl}.sha256`, { signal: AbortSignal.timeout(30000) }) + if (!checksumResp.ok) throw new Error(`standalone 校验文件不可用 (HTTP ${checksumResp.status})`) + const checksumText = await checksumResp.text() + const expected = checksumText.match(/\b[0-9a-fA-F]{64}\b/)?.[0]?.toLowerCase() + if (!expected) throw new Error('standalone 校验文件格式无效') + const actual = crypto.createHash('sha256').update(buffer).digest('hex') + if (actual !== expected) throw new Error(`standalone SHA-256 校验失败:expected=${expected}, actual=${actual}`) +} + +function verifyStandaloneInstall(stagingDir, remoteVersion) { + const binFile = isWindows ? 'openclaw.cmd' : 'openclaw' + const cliPath = path.join(stagingDir, binFile) + if (!fs.existsSync(cliPath)) throw new Error('standalone 解压后未找到 openclaw 可执行文件') + const verifiedVersion = readVersionFromInstallation(cliPath) + if (!verifiedVersion) throw new Error('standalone 安装后无法读取目标 CLI 版本') + if (!versionsMatch(verifiedVersion, remoteVersion)) { + throw new Error(`standalone 安装校验失败:目标 CLI 版本为 ${verifiedVersion},清单版本为 ${remoteVersion}`) + } + return verifiedVersion +} + +export function replaceStandaloneInstall(stagingDir, installDir, backupDir) { + let oldInstallMoved = false + try { + if (fs.existsSync(backupDir)) fs.rmSync(backupDir, { recursive: true, force: true }) + if (fs.existsSync(installDir)) { + fs.renameSync(installDir, backupDir) + oldInstallMoved = true + } + fs.renameSync(stagingDir, installDir) + if (oldInstallMoved) fs.rmSync(backupDir, { recursive: true, force: true }) + } catch (error) { + if (!fs.existsSync(installDir) && oldInstallMoved && fs.existsSync(backupDir)) { + try { fs.renameSync(backupDir, installDir) } catch {} + } + throw error + } +} + async function _tryStandaloneInstall(version, logs, overrideBaseUrl = null) { const cfg = standaloneConfig() if (!cfg.enabled || !cfg.baseUrl) return false @@ -1479,23 +1528,27 @@ async function _tryStandaloneInstall(version, logs, overrideBaseUrl = null) { logs.push('📦 尝试 standalone 独立安装包(汉化版专属,自带 Node.js 运行时,无需 npm)') logs.push('查询最新版本...') - const manifestUrl = `${cfg.baseUrl}/latest.json` - const resp = await globalThis.fetch(manifestUrl, { signal: AbortSignal.timeout(10000) }) - if (!resp.ok) throw new Error(`standalone 清单不可用 (HTTP ${resp.status})`) - const manifest = await resp.json() - - // 兼容两种 latest.json 格式: - // 新格式(CI 生成): { "editions": { "zh": { "version": "...", "base_url": "..." } } } - // 旧格式(兼容): { "version": "...", "base_url": "..." } - const editionObj = manifest?.editions?.zh - const remoteVersion = editionObj?.version || manifest.version - if (!remoteVersion) throw new Error('standalone 清单缺少 version 字段') - if (version !== 'latest' && !versionsMatch(remoteVersion, version)) { - throw new Error(`standalone 版本 ${remoteVersion} 与请求版本 ${version} 不匹配`) + let remoteVersion + let manifestBaseUrl = null + let archivePrefix = 'openclaw-zh' + if (overrideBaseUrl && version !== 'latest') { + remoteVersion = version + } else { + const manifestUrl = `${cfg.baseUrl}/latest.json` + const resp = await globalThis.fetch(manifestUrl, { signal: AbortSignal.timeout(10000) }) + if (!resp.ok) throw new Error(`standalone 清单不可用 (HTTP ${resp.status})`) + const manifest = await resp.json() + // 兼容新旧两种 latest.json 格式。 + const editionObj = manifest?.editions?.zh + remoteVersion = editionObj?.version || manifest.version + if (!remoteVersion) throw new Error('standalone 清单缺少 version 字段') + if (version !== 'latest' && !versionsMatch(remoteVersion, version)) { + throw new Error(`standalone 版本 ${remoteVersion} 与请求版本 ${version} 不匹配`) + } + archivePrefix = editionObj ? 'openclaw-zh' : 'openclaw' + manifestBaseUrl = editionObj?.base_url || manifest.base_url } - const archivePrefix = editionObj ? 'openclaw-zh' : 'openclaw' - const manifestBaseUrl = editionObj?.base_url || manifest.base_url const remoteBase = overrideBaseUrl || manifestBaseUrl || `${cfg.baseUrl}/${remoteVersion}` const ext = isWindows ? 'zip' : 'tar.gz' const filename = `${archivePrefix}-${remoteVersion}-${platform}.${ext}` @@ -1508,47 +1561,41 @@ async function _tryStandaloneInstall(version, logs, overrideBaseUrl = null) { if (!dlResp.ok) throw new Error(`standalone 下载失败 (HTTP ${dlResp.status})`) const buffer = Buffer.from(await dlResp.arrayBuffer()) const sizeMb = (buffer.length / 1048576).toFixed(0) - logs.push(`下载完成 (${sizeMb}MB),解压安装中...`) + logs.push(`下载完成 (${sizeMb}MB),正在校验 SHA-256...`) + await verifyStandaloneArchiveChecksum(downloadUrl, buffer) + logs.push('SHA-256 校验通过,解压安装中...') fs.writeFileSync(tmpPath, buffer) - // 清理旧安装 & 解压 - if (fs.existsSync(installDir)) { - fs.rmSync(installDir, { recursive: true, force: true }) - } - fs.mkdirSync(installDir, { recursive: true }) + const stagingDir = `${installDir}.staging` + const backupDir = `${installDir}.backup` + if (!fs.existsSync(installDir) && fs.existsSync(backupDir)) fs.renameSync(backupDir, installDir) + if (fs.existsSync(stagingDir)) fs.rmSync(stagingDir, { recursive: true, force: true }) + if (fs.existsSync(backupDir)) fs.rmSync(backupDir, { recursive: true, force: true }) + fs.mkdirSync(stagingDir, { recursive: true }) if (isWindows) { // Windows: 用 PowerShell 解压 zip - execSync(`powershell -NoProfile -Command "Expand-Archive -Path '${tmpPath}' -DestinationPath '${installDir}' -Force"`, { windowsHide: true }) + execSync(`powershell -NoProfile -Command "Expand-Archive -Path '${tmpPath}' -DestinationPath '${stagingDir}' -Force"`, { windowsHide: true }) // 处理嵌套 openclaw/ 目录 - const nested = path.join(installDir, 'openclaw') + const nested = path.join(stagingDir, 'openclaw') if (fs.existsSync(nested) && fs.existsSync(path.join(nested, 'node.exe'))) { for (const entry of fs.readdirSync(nested)) { - fs.renameSync(path.join(nested, entry), path.join(installDir, entry)) + fs.renameSync(path.join(nested, entry), path.join(stagingDir, entry)) } fs.rmSync(nested, { recursive: true, force: true }) } } else { // Unix: tar 解压 - execSync(`tar -xzf "${tmpPath}" -C "${installDir}" --strip-components=1`, { windowsHide: true }) + execSync(`tar -xzf "${tmpPath}" -C "${stagingDir}" --strip-components=1`, { windowsHide: true }) } try { fs.unlinkSync(tmpPath) } catch {} - // 验证 + // 先验证 staging,再以同盘 rename 原子切换;失败时恢复旧安装。 const binFile = isWindows ? 'openclaw.cmd' : 'openclaw' - if (!fs.existsSync(path.join(installDir, binFile))) { - throw new Error('standalone 解压后未找到 openclaw 可执行文件') - } - + const verifiedVersion = verifyStandaloneInstall(stagingDir, remoteVersion) + replaceStandaloneInstall(stagingDir, installDir, backupDir) const cliPath = path.join(installDir, binFile) - const verifiedVersion = readVersionFromInstallation(cliPath) - if (!verifiedVersion) { - throw new Error('standalone 安装后无法读取目标 CLI 版本,已保留旧绑定') - } - if (!versionsMatch(verifiedVersion, remoteVersion)) { - throw new Error(`standalone 安装校验失败:目标 CLI 版本为 ${verifiedVersion},清单版本为 ${remoteVersion},已保留旧绑定`) - } logs.push(`目标 CLI 验证通过: ${cliPath} (${verifiedVersion})`) try { bindOpenclawCliPath(cliPath) @@ -1559,7 +1606,7 @@ async function _tryStandaloneInstall(version, logs, overrideBaseUrl = null) { logs.push(`✅ standalone 安装完成 (${verifiedVersion})`) logs.push(`安装目录: ${installDir}`) - logs.push('旧安装已保留。如需清理,请在“安装管理与清理”里确认后处理。') + logs.push('升级已原子切换;如安装失败会自动恢复原版本。') return true } @@ -2453,7 +2500,16 @@ const CALIBRATION_RESET_INHERIT_KEYS = [ 'wizard', ] -function requiredControlUiOrigins() { +function normalizeControlUiOrigin(value) { + try { + const parsed = new URL(String(value || '').trim()) + return ['http:', 'https:'].includes(parsed.protocol) ? parsed.origin : null + } catch { + return null + } +} + +function requiredControlUiOrigins(additionalOrigin = null) { const origins = [ 'tauri://localhost', 'https://tauri.localhost', @@ -2468,6 +2524,8 @@ function requiredControlUiOrigins() { origins.push(`http://${ip}:1420`) origins.push(`http://${ip}:18777`) } + const normalizedOrigin = normalizeControlUiOrigin(additionalOrigin) + if (normalizedOrigin) origins.push(normalizedOrigin) return [...new Set(origins)] } @@ -2780,10 +2838,10 @@ function wsReadLoop(socket, onMessage, timeoutMs = DOCKER_TASK_TIMEOUT_MS) { return cancel } -function patchGatewayOrigins() { +function patchGatewayOrigins(additionalOrigin = null) { if (!fs.existsSync(CONFIG_PATH)) return false const config = readOpenclawConfigRequired() - const origins = requiredControlUiOrigins() + const origins = requiredControlUiOrigins(additionalOrigin) const existing = config?.gateway?.controlUi?.allowedOrigins || [] // 合并:保留用户已有的 origins,只追加 ClawPanel 需要的 const merged = [...new Set([...existing, ...origins])] @@ -13025,6 +13083,7 @@ const handlers = { const gitConfigured = configureGitHttpsRules() const gitEnv = buildGitInstallEnv() logs.push(`Git HTTPS 规则已就绪 (${gitConfigured}/${GIT_HTTPS_REWRITES.length})`) + ensureTargetNodeRuntimeCompatibleForNpm(ver) const runInstall = (targetRegistry) => execSync( `${npmBin} install -g ${pkg}@${ver} --force --registry ${targetRegistry} --verbose 2>&1`, { timeout: 120000, windowsHide: true, env: gitEnv } @@ -13198,8 +13257,8 @@ const handlers = { }, // 设备配对 + Gateway 握手 - auto_pair_device() { - const originsChanged = patchGatewayOrigins() + auto_pair_device({ origin } = {}) { + const originsChanged = patchGatewayOrigins(origin) const { deviceId, publicKey } = getOrCreateDeviceKey() if (!fs.existsSync(DEVICES_DIR)) fs.mkdirSync(DEVICES_DIR, { recursive: true }) let paired = {} diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 3997d41..5d9dc36 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -369,6 +369,26 @@ pub(crate) fn ensure_node_runtime_compatible() -> Result<(), String> { )) } +fn ensure_target_node_runtime_compatible_for_npm(version: &str) -> Result<(), String> { + let Some(requirement) = fallback_openclaw_node_requirement(version) else { + return Ok(()); + }; + let enhanced = super::enhanced_path(); + let node_path = find_node_path(&enhanced).ok_or_else(|| { + format!( + "无法通过 npm 安装 OpenClaw {version}:未检测到系统 Node.js。目标版本要求 {requirement},请先安装兼容版本,或选择自带 Node.js 的 standalone 安装。" + ) + })?; + let current = node_version_from_bin(std::path::Path::new(&node_path)) + .ok_or_else(|| format!("无法读取系统 Node.js 版本:{node_path}"))?; + if !node_version_satisfies_requirement(¤t, requirement) { + return Err(format!( + "无法通过 npm 安装 OpenClaw {version}:当前 Node.js {current},目标版本要求 {requirement}。请先升级 Node.js,或选择自带 Node.js 的 standalone 安装。" + )); + } + Ok(()) +} + /// 提取基础版本号(去掉 -zh.x / -nightly.xxx 等后缀,只保留主版本数字部分) /// "2026.3.13-zh.1" → "2026.3.13", "2026.3.13" → "2026.3.13" fn base_version(v: &str) -> String { @@ -2685,17 +2705,13 @@ fn should_fallback_standalone_to_npm( fn standalone_install_version( requested_version: Option<&str>, recommended_version: Option<&str>, - method: &str, - portable_mode: bool, + _method: &str, + _portable_mode: bool, ) -> String { if let Some(version) = requested_version { return version.to_string(); } - if portable_mode || method == "standalone-r2" || method == "standalone-github" { - return "latest".to_string(); - } - recommended_version.unwrap_or("latest").to_string() } @@ -3469,6 +3485,67 @@ fn npm_openclaw_cli_path() -> Option { } } +fn standalone_work_dir(install_dir: &std::path::Path, suffix: &str) -> std::path::PathBuf { + let name = install_dir + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("openclaw"); + install_dir + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join(format!("{name}.{suffix}")) +} + +fn verify_standalone_install( + staging_dir: &std::path::Path, + remote_version: &str, +) -> Result { + #[cfg(target_os = "windows")] + let openclaw_bin = staging_dir.join("openclaw.cmd"); + #[cfg(not(target_os = "windows"))] + let openclaw_bin = staging_dir.join("openclaw"); + + if !openclaw_bin.exists() { + return Err("standalone 解压后未找到 openclaw 可执行文件".into()); + } + let verified_version = read_version_from_installation(&openclaw_bin) + .ok_or_else(|| "standalone 安装后无法读取目标 CLI 版本".to_string())?; + if !versions_match(&verified_version, remote_version) { + return Err(format!( + "standalone 安装校验失败:目标 CLI 版本为 {verified_version},清单版本为 {remote_version}" + )); + } + Ok(verified_version) +} + +fn replace_standalone_install( + staging_dir: &std::path::Path, + install_dir: &std::path::Path, + backup_dir: &std::path::Path, +) -> Result<(), String> { + if backup_dir.exists() { + std::fs::remove_dir_all(backup_dir).map_err(|e| format!("清理旧升级备份失败: {e}"))?; + } + let old_install_moved = if install_dir.exists() { + std::fs::rename(install_dir, backup_dir) + .map_err(|e| format!("备份当前 standalone 安装失败: {e}"))?; + true + } else { + false + }; + + if let Err(error) = std::fs::rename(staging_dir, install_dir) { + if old_install_moved && !install_dir.exists() && backup_dir.exists() { + let _ = std::fs::rename(backup_dir, install_dir); + } + return Err(format!("激活新 standalone 安装失败,已恢复原版本: {error}")); + } + if old_install_moved { + std::fs::remove_dir_all(backup_dir).map_err(|e| format!("清理升级备份失败: {e}"))?; + } + Ok(()) +} + /// 尝试从 standalone 独立安装包安装 OpenClaw(自带 Node.js,零依赖) /// 动态查询 latest.json 获取最新版本,下载对应平台的归档并解压 /// 成功返回 Ok(版本号),失败返回 Err(原因) 供 caller 降级到 R2/npm @@ -3477,6 +3554,7 @@ async fn try_standalone_install( version: &str, override_base_url: Option<&str>, ) -> Result { + use sha2::{Digest, Sha256}; let source_label = if override_base_url.is_some() { "GitHub" } else { @@ -3501,64 +3579,71 @@ async fn try_standalone_install( ); } - // 1. 动态查询最新版本 + // 1. GitHub 固定版本直接解析;只有 latest 或 CDN 路径需要清单。 let _ = app.emit( "upgrade-log", "\u{1F4E6} 尝试 standalone 独立安装包(汉化版专属,自带 Node.js 运行时,无需 npm)", ); - let _ = app.emit("upgrade-log", "查询最新版本..."); - let manifest_url = format!("{base_url}/latest.json"); let client = crate::commands::build_http_client(std::time::Duration::from_secs(10), None) .map_err(|e| format!("HTTP 客户端创建失败: {e}"))?; - let manifest_resp = client - .get(&manifest_url) - .send() - .await - .map_err(|e| format!("standalone 清单获取失败: {e}"))?; - if !manifest_resp.status().is_success() { - return Err(format!( - "standalone 清单不可用 (HTTP {})", - manifest_resp.status() - )); - } - let manifest: Value = manifest_resp - .json() - .await - .map_err(|e| format!("standalone 清单解析失败: {e}"))?; + let (remote_version, manifest_base_url, archive_prefix): (String, Option, &str) = + if override_base_url.is_some() && version != "latest" { + (version.to_string(), None, "openclaw-zh") + } else { + let _ = app.emit("upgrade-log", "查询最新版本..."); + let manifest_url = format!("{base_url}/latest.json"); + let manifest_resp = client + .get(&manifest_url) + .send() + .await + .map_err(|e| format!("standalone 清单获取失败: {e}"))?; + if !manifest_resp.status().is_success() { + return Err(format!( + "standalone 清单不可用 (HTTP {})", + manifest_resp.status() + )); + } + let manifest: Value = manifest_resp + .json() + .await + .map_err(|e| format!("standalone 清单解析失败: {e}"))?; + let edition_obj = manifest.get("editions").and_then(|e| e.get("zh")); + if let Some(ed) = edition_obj { + let ver = ed + .get("version") + .and_then(|v| v.as_str()) + .ok_or("standalone 清单 editions.zh 缺少 version 字段")?; + let bu = ed + .get("base_url") + .and_then(|v| v.as_str()) + .map(str::to_string); + (ver.to_string(), bu, "openclaw-zh") + } else { + let ver = manifest + .get("version") + .and_then(|v| v.as_str()) + .ok_or("standalone 清单缺少 version 字段")?; + let bu = manifest + .get("base_url") + .and_then(|v| v.as_str()) + .map(str::to_string); + (ver.to_string(), bu, "openclaw") + } + }; - // 兼容两种 latest.json 格式: - // 新格式(CI 生成): { "editions": { "zh": { "version": "...", "base_url": "..." } } } - // 旧格式(兼容): { "version": "...", "base_url": "..." } - let edition_obj = manifest.get("editions").and_then(|e| e.get("zh")); - let (remote_version, manifest_base_url, archive_prefix) = if let Some(ed) = edition_obj { - let ver = ed - .get("version") - .and_then(|v| v.as_str()) - .ok_or("standalone 清单 editions.zh 缺少 version 字段")?; - let bu = ed.get("base_url").and_then(|v| v.as_str()); - (ver, bu, "openclaw-zh") - } else { - let ver = manifest - .get("version") - .and_then(|v| v.as_str()) - .ok_or("standalone 清单缺少 version 字段")?; - let bu = manifest.get("base_url").and_then(|v| v.as_str()); - (ver, bu, "openclaw") - }; - - // 版本匹配检查 - if version != "latest" && !versions_match(remote_version, version) { + if version != "latest" && !versions_match(&remote_version, version) { return Err(format!( "standalone 版本 {remote_version} 与请求版本 {version} 不匹配" )); } let default_base = format!("{base_url}/{remote_version}"); - let override_base = override_base_url.map(|ovr| ovr.replace("{version}", remote_version)); - let remote_base = if let Some(ovr) = override_base.as_deref() { - ovr + let remote_base = if let Some(override_url) = override_base_url { + override_url.replace("{version}", &remote_version) + } else if let Some(manifest_url) = manifest_base_url { + manifest_url } else { - manifest_base_url.unwrap_or(&default_base) + default_base }; // 2. 构造下载 URL @@ -3593,13 +3678,14 @@ async fn try_standalone_install( }; let _ = app.emit("upgrade-log", format!("下载中 ({size_mb})...")); - { + let actual_sha = { use futures_util::StreamExt; use tokio::io::AsyncWriteExt; let mut file = tokio::fs::File::create(&archive_path) .await .map_err(|e| format!("创建临时文件失败: {e}"))?; let mut stream = dl_resp.bytes_stream(); + let mut hasher = Sha256::new(); let mut downloaded: u64 = 0; let mut last_progress: u32 = 15; while let Some(chunk) = stream.next().await { @@ -3607,6 +3693,7 @@ async fn try_standalone_install( file.write_all(&chunk) .await .map_err(|e| format!("写入失败: {e}"))?; + hasher.update(&chunk); downloaded += chunk.len() as u64; if total_bytes > 0 { let pct = 15 + ((downloaded as f64 / total_bytes as f64) * 55.0) as u32; @@ -3629,17 +3716,53 @@ async fn try_standalone_install( file.flush() .await .map_err(|e| format!("刷新文件失败: {e}"))?; - } + format!("{:x}", hasher.finalize()) + }; - let _ = app.emit("upgrade-log", "下载完成,解压安装中..."); + let _ = app.emit("upgrade-log", "下载完成,正在校验 SHA-256..."); + let checksum_url = format!("{download_url}.sha256"); + let checksum_resp = dl_client + .get(&checksum_url) + .send() + .await + .map_err(|e| format!("standalone 校验文件下载失败: {e}"))?; + if !checksum_resp.status().is_success() { + return Err(format!( + "standalone 校验文件不可用 (HTTP {})", + checksum_resp.status() + )); + } + let checksum_text = checksum_resp + .text() + .await + .map_err(|e| format!("standalone 校验文件读取失败: {e}"))?; + let expected_sha = checksum_text + .split_whitespace() + .find(|value| value.len() == 64 && value.chars().all(|ch| ch.is_ascii_hexdigit())) + .map(str::to_ascii_lowercase) + .ok_or("standalone 校验文件格式无效")?; + if actual_sha != expected_sha { + return Err(format!( + "standalone SHA-256 校验失败:expected={expected_sha}, actual={actual_sha}" + )); + } + let _ = app.emit("upgrade-log", "SHA-256 校验通过,解压安装中..."); let _ = app.emit("upgrade-progress", 72); - // 4. 清理旧安装 & 创建目录 - if install_dir.exists() { - std::fs::remove_dir_all(&install_dir) - .map_err(|e| format!("清理旧 standalone 安装目录失败: {e}"))?; + // 4. 解压到同盘 staging,验证通过后再原子切换。 + let staging_dir = standalone_work_dir(&install_dir, "staging"); + let backup_dir = standalone_work_dir(&install_dir, "backup"); + if !install_dir.exists() && backup_dir.exists() { + std::fs::rename(&backup_dir, &install_dir) + .map_err(|e| format!("恢复上次 standalone 升级备份失败: {e}"))?; } - std::fs::create_dir_all(&install_dir).map_err(|e| format!("创建安装目录失败: {e}"))?; + if staging_dir.exists() { + std::fs::remove_dir_all(&staging_dir).map_err(|e| format!("清理 staging 目录失败: {e}"))?; + } + if backup_dir.exists() { + std::fs::remove_dir_all(&backup_dir).map_err(|e| format!("清理旧升级备份失败: {e}"))?; + } + std::fs::create_dir_all(&staging_dir).map_err(|e| format!("创建 staging 目录失败: {e}"))?; // 5. 解压 #[cfg(target_os = "windows")] @@ -3650,10 +3773,10 @@ async fn try_standalone_install( let mut zip_archive = zip::ZipArchive::new(archive_file).map_err(|e| format!("ZIP 解析失败: {e}"))?; zip_archive - .extract(&install_dir) + .extract(&staging_dir) .map_err(|e| format!("ZIP 解压失败: {e}"))?; // 归档内可能有 openclaw/ 子目录,需要提升一层 - promote_nested_standalone_dir(&install_dir, "node.exe")?; + promote_nested_standalone_dir(&staging_dir, "node.exe")?; } #[cfg(not(target_os = "windows"))] { @@ -3663,7 +3786,7 @@ async fn try_standalone_install( "-xzf", &archive_path.to_string_lossy(), "-C", - &install_dir.to_string_lossy(), + &staging_dir.to_string_lossy(), "--strip-components=1", ]) .status() @@ -3677,23 +3800,15 @@ async fn try_standalone_install( let _ = std::fs::remove_file(&archive_path); let _ = app.emit("upgrade-progress", 85); - // 6. 验证安装 + // 6. 验证 staging 并切换 + let verified_version = verify_standalone_install(&staging_dir, &remote_version)?; + replace_standalone_install(&staging_dir, &install_dir, &backup_dir)?; + #[cfg(target_os = "windows")] let openclaw_bin = install_dir.join("openclaw.cmd"); #[cfg(not(target_os = "windows"))] let openclaw_bin = install_dir.join("openclaw"); - if !openclaw_bin.exists() { - return Err("standalone 解压后未找到 openclaw 可执行文件".into()); - } - - let verified_version = read_version_from_installation(&openclaw_bin) - .ok_or_else(|| "standalone 安装后无法读取目标 CLI 版本,已保留旧绑定".to_string())?; - if !versions_match(&verified_version, remote_version) { - return Err(format!( - "standalone 安装校验失败:目标 CLI 版本为 {verified_version},清单版本为 {remote_version},已保留旧绑定" - )); - } let _ = app.emit( "upgrade-log", format!( @@ -4170,7 +4285,7 @@ async fn upgrade_openclaw_inner( let _ = app.emit("upgrade-log", &msg); let _ = app.emit( "upgrade-log", - "旧安装已保留。如需清理,请在“安装管理与清理”里确认后处理。", + "升级已原子切换;如安装失败会自动恢复原版本。", ); return Ok(msg); } @@ -4189,7 +4304,7 @@ async fn upgrade_openclaw_inner( let _ = app.emit("upgrade-log", &msg); let _ = app.emit( "upgrade-log", - "旧安装已保留。如需清理,请在“安装管理与清理”里确认后处理。", + "升级已原子切换;如安装失败会自动恢复原版本。", ); return Ok(msg); } @@ -4213,7 +4328,7 @@ async fn upgrade_openclaw_inner( let _ = app.emit("upgrade-log", &msg); let _ = app.emit( "upgrade-log", - "旧安装已保留。如需清理,请在“安装管理与清理”里确认后处理。", + "升级已原子切换;如安装失败会自动恢复原版本。", ); return Ok(msg); } @@ -4250,6 +4365,8 @@ async fn upgrade_openclaw_inner( // ── npm install(兜底或用户明确选择) ── + ensure_target_node_runtime_compatible_for_npm(ver)?; + // 切换源时需要卸载旧包,但为避免安装失败导致 CLI 丢失, // 先安装新包,成功后再卸载旧包 let old_pkg = npm_package_name(¤t_source); @@ -7732,6 +7849,7 @@ mod write_openclaw_config_merge_tests { use super::node_version_satisfies_requirement; use super::path_without_curdir_string; use super::promote_nested_standalone_dir; + use super::replace_standalone_install; #[cfg(target_os = "windows")] use super::resolve_openclaw_cli_input_path; use super::select_calibration_source; @@ -7751,6 +7869,49 @@ mod write_openclaw_config_merge_tests { std::env::temp_dir().join(format!("clawpanel-{name}-{}-{suffix}", std::process::id())) } + #[test] + fn standalone_activation_replaces_verified_staging() { + let root = unique_temp_dir("standalone-swap"); + let install_dir = root.join("install"); + let staging_dir = root.join("staging"); + let backup_dir = root.join("backup"); + std::fs::create_dir_all(&install_dir).unwrap(); + std::fs::create_dir_all(&staging_dir).unwrap(); + std::fs::write(install_dir.join("old.txt"), "old").unwrap(); + std::fs::write(staging_dir.join("new.txt"), "new").unwrap(); + + replace_standalone_install(&staging_dir, &install_dir, &backup_dir).unwrap(); + + assert_eq!( + std::fs::read_to_string(install_dir.join("new.txt")).unwrap(), + "new" + ); + assert!(!install_dir.join("old.txt").exists()); + assert!(!backup_dir.exists()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn standalone_activation_restores_old_install_on_failure() { + let root = unique_temp_dir("standalone-rollback"); + let install_dir = root.join("install"); + let missing_staging_dir = root.join("missing-staging"); + let backup_dir = root.join("backup"); + std::fs::create_dir_all(&install_dir).unwrap(); + std::fs::write(install_dir.join("old.txt"), "old").unwrap(); + + assert!( + replace_standalone_install(&missing_staging_dir, &install_dir, &backup_dir).is_err() + ); + + assert_eq!( + std::fs::read_to_string(install_dir.join("old.txt")).unwrap(), + "old" + ); + assert!(!backup_dir.exists()); + let _ = std::fs::remove_dir_all(root); + } + /// Regression guard: Issue #127 merge keeps full provider map when the UI payload /// only touches one provider — `sync_providers_to_agent_models` must use the same /// merged view (see `write_openclaw_config`), not the raw `config` argument. @@ -8111,22 +8272,22 @@ mod write_openclaw_config_merge_tests { } #[test] - fn standalone_version_uses_latest_for_portable_without_explicit_version() { + fn standalone_version_uses_recommended_for_portable_without_explicit_version() { assert_eq!( standalone_install_version(None, Some("2026.5.18-zh.1"), "auto", true), - "latest" + "2026.5.18-zh.1" ); } #[test] - fn standalone_version_uses_latest_for_explicit_standalone_method() { + fn standalone_version_uses_recommended_for_explicit_standalone_method() { assert_eq!( standalone_install_version(None, Some("2026.5.18-zh.1"), "standalone-r2", false), - "latest" + "2026.5.18-zh.1" ); assert_eq!( standalone_install_version(None, Some("2026.5.18-zh.1"), "standalone-github", false), - "latest" + "2026.5.18-zh.1" ); } diff --git a/src-tauri/src/commands/pairing.rs b/src-tauri/src/commands/pairing.rs index c604467..1d895ca 100644 --- a/src-tauri/src/commands/pairing.rs +++ b/src-tauri/src/commands/pairing.rs @@ -228,10 +228,10 @@ fn now_ms() -> u64 { } #[tauri::command] -pub fn auto_pair_device() -> Result { +pub fn auto_pair_device(origin: Option) -> Result { // 无论是否已配对,都确保 gateway.controlUi.allowedOrigins 已写入 // 必须在最前面,避免因设备密钥不存在而跳过 - patch_gateway_origins(); + patch_gateway_origins(origin.as_deref()); // 获取或生成设备密钥(首次安装时自动创建) let (device_id, public_key, _) = super::device::get_or_create_key()?; @@ -308,19 +308,22 @@ pub fn auto_pair_device() -> Result { /// 将 Tauri 应用的 origin 写入 gateway.controlUi.allowedOrigins /// 避免 Gateway 因 origin not allowed 拒绝 WebSocket 握手 -fn patch_gateway_origins() { +fn patch_gateway_origins(additional_origin: Option<&str>) { let Ok(config) = super::config::load_openclaw_json() else { return; }; // Tauri 应用 + 本地开发服务器必须存在的 origin - let required: Vec = vec![ + let mut required: Vec = vec![ "tauri://localhost".into(), "https://tauri.localhost".into(), "http://tauri.localhost".into(), "http://localhost:1420".into(), "http://127.0.0.1:1420".into(), ]; + if let Some(origin) = additional_origin.and_then(normalize_control_ui_origin) { + required.push(origin); + } let existing: Vec = config .pointer("/gateway/controlUi/allowedOrigins") @@ -357,6 +360,14 @@ fn patch_gateway_origins() { let _ = super::config::save_openclaw_json(&patch); } +fn normalize_control_ui_origin(value: &str) -> Option { + let parsed = reqwest::Url::parse(value.trim()).ok()?; + if !matches!(parsed.scheme(), "http" | "https") { + return None; + } + Some(parsed.origin().ascii_serialization()) +} + #[tauri::command] pub fn check_pairing_status() -> Result { // 读取设备密钥 diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 68efad6..467d230 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -433,7 +433,7 @@ export const api = { createConnectFrame: (nonce, gatewayToken, gatewayPassword) => invoke('create_connect_frame', { nonce, gatewayToken, gatewayPassword: gatewayPassword || null }), // 设备配对 - autoPairDevice: () => invoke('auto_pair_device'), + autoPairDevice: (origin = window.location.origin) => invoke('auto_pair_device', { origin: origin || null }), checkPairingStatus: () => invoke('check_pairing_status'), pairingListChannel: (channel) => invoke('pairing_list_channel', { channel }), pairingApproveChannel: (channel, code, notify = false) => invoke('pairing_approve_channel', { channel, code, notify }), diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js index b78103d..4005f6b 100644 --- a/src/lib/ws-client.js +++ b/src/lib/ws-client.js @@ -587,7 +587,7 @@ export class WsClient { this._autoPairAttempts++ try { console.log('[ws] 执行自动配对(第', this._autoPairAttempts, '次)...') - const result = await api.autoPairDevice() + const result = await api.autoPairDevice(window.location.origin) console.log('[ws] 配对结果:', result) // 这里只修配对文件,不自动重启 Gateway。 @@ -625,7 +625,7 @@ export class WsClient { this._url = `${base}?token=${encodeURIComponent(this._token)}` } // 确保配对和 origins - try { await api.autoPairDevice() } catch {} + try { await api.autoPairDevice(window.location.origin) } catch {} // 3秒后重连 setTimeout(() => { if (!this._intentionalClose) { diff --git a/src/pages/setup.js b/src/pages/setup.js index 8701a57..d346508 100644 --- a/src/pages/setup.js +++ b/src/pages/setup.js @@ -425,7 +425,7 @@ function renderSteps(page, { node, git, cliOk, config, version }) { // 第三步:OpenClaw CLI html += ` -
+
${stepIcon(cliOk)} OpenClaw CLI
@@ -541,7 +541,7 @@ function renderSteps(page, { node, git, cliOk, config, version }) { } stepsEl.innerHTML = html - bindEvents(page, nodeOk, { node, git, cliOk, config }) + bindEvents(page, { node, git, cliOk, config }) } function renderInstallSection() { @@ -705,7 +705,7 @@ ${problems.join('\n')} ${t('setup.promptOutro')}` } -function bindEvents(page, nodeOk, detectState) { +function bindEvents(page, detectState) { // 打开 AI 助手 page.querySelector('#btn-goto-assistant')?.addEventListener('click', () => { window.location.hash = '/assistant' @@ -1166,7 +1166,7 @@ function bindEvents(page, nodeOk, detectState) { // 一键安装 const installBtn = page.querySelector('#btn-install') - if (!installBtn || !nodeOk) return + if (!installBtn) return installBtn.addEventListener('click', async () => { const source = page.querySelector('input[name="install-source"]:checked')?.value || 'chinese' diff --git a/tests/openclaw-install-safety.test.js b/tests/openclaw-install-safety.test.js new file mode 100644 index 0000000..f4af0ea --- /dev/null +++ b/tests/openclaw-install-safety.test.js @@ -0,0 +1,119 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { replaceStandaloneInstall } from '../scripts/dev-api.js' + +const setup = readFileSync(new URL('../src/pages/setup.js', import.meta.url), 'utf8') +const tauriApi = readFileSync(new URL('../src/lib/tauri-api.js', import.meta.url), 'utf8') +const wsClient = readFileSync(new URL('../src/lib/ws-client.js', import.meta.url), 'utf8') +const devApi = readFileSync(new URL('../scripts/dev-api.js', import.meta.url), 'utf8') +const rustConfig = readFileSync(new URL('../src-tauri/src/commands/config.rs', import.meta.url), 'utf8') + +function sliceFunction(source, startMarker, endMarker) { + const start = source.indexOf(startMarker) + const end = source.indexOf(endMarker, start + startMarker.length) + return start >= 0 && end > start ? source.slice(start, end) : '' +} + +test('setup keeps standalone installation available without a system Node runtime', () => { + const cliSection = sliceFunction(setup, '// 第三步:OpenClaw CLI', '// 第四步:') + assert.doesNotMatch( + cliSection, + /opacity:0\.65;pointer-events:none/, + ) + assert.match(setup, /if \(!installBtn\) return/) + assert.doesNotMatch(setup, /if \(!installBtn \|\| !nodeOk\) return/) +}) + +test('npm installation checks the target OpenClaw Node requirement before running npm', () => { + const webUpgrade = sliceFunction(devApi, 'async upgrade_openclaw(', '// 设备配对 + Gateway 握手') + assert.match(webUpgrade, /ensureTargetNodeRuntimeCompatibleForNpm\(ver\)/) + assert.ok( + webUpgrade.indexOf('ensureTargetNodeRuntimeCompatibleForNpm(ver)') < webUpgrade.indexOf('const runInstall'), + 'Web target runtime check must run before npm install', + ) + + const rustUpgrade = sliceFunction(rustConfig, 'async fn upgrade_openclaw_inner(', '#[tauri::command]\npub async fn uninstall_openclaw') + assert.match(rustUpgrade, /ensure_target_node_runtime_compatible_for_npm\(ver\)\?/) + assert.ok( + rustUpgrade.indexOf('ensure_target_node_runtime_compatible_for_npm(ver)?') < rustUpgrade.indexOf('pre_install_cleanup();'), + 'Desktop target runtime check must run before cleanup and npm install', + ) +}) + +test('standalone installation validates a staging directory before replacing the active install', () => { + const webInstall = sliceFunction(devApi, 'async function _tryStandaloneInstall(', 'function r2PlatformKey()') + assert.match(webInstall, /stagingDir/) + assert.match(webInstall, /backupDir/) + assert.match(webInstall, /verifyStandaloneInstall/) + assert.ok( + webInstall.indexOf('verifyStandaloneInstall') < webInstall.indexOf('replaceStandaloneInstall'), + 'Web standalone archive must be verified before activation', + ) + + const rustInstall = sliceFunction(rustConfig, 'async fn try_standalone_install(', '/// 尝试从 R2 CDN') + assert.match(rustInstall, /staging_dir/) + assert.match(rustInstall, /backup_dir/) + assert.match(rustInstall, /verify_standalone_install/) + assert.ok( + rustInstall.indexOf('verify_standalone_install') < rustInstall.indexOf('replace_standalone_install'), + 'Desktop standalone archive must be verified before activation', + ) +}) + +test('GitHub standalone fallback can resolve a pinned version without the CDN manifest', () => { + const webInstall = sliceFunction(devApi, 'async function _tryStandaloneInstall(', 'function r2PlatformKey()') + assert.match(webInstall, /overrideBaseUrl && version !== 'latest'/) + assert.match(webInstall, /remoteVersion = version/) + + const rustInstall = sliceFunction(rustConfig, 'async fn try_standalone_install(', '/// 尝试从 R2 CDN') + assert.match(rustInstall, /override_base_url\.is_some\(\) && version != "latest"/) +}) + +test('Web Gateway pairing sends the actual browser origin to the backend', () => { + assert.match(tauriApi, /autoPairDevice: \(origin = window\.location\.origin\) => invoke\('auto_pair_device', \{ origin: origin \|\| null \}\)/) + assert.match(wsClient, /api\.autoPairDevice\(window\.location\.origin\)/) + assert.match(devApi, /auto_pair_device\(\{ origin \} = \{\}\)/) + assert.match(devApi, /patchGatewayOrigins\(origin\)/) +}) + +test('Web standalone activation replaces a verified staging directory', () => { + const root = mkdtempSync(join(tmpdir(), 'clawpanel-standalone-swap-')) + const installDir = join(root, 'install') + const stagingDir = join(root, 'staging') + const backupDir = join(root, 'backup') + try { + mkdirSync(installDir) + mkdirSync(stagingDir) + writeFileSync(join(installDir, 'old.txt'), 'old') + writeFileSync(join(stagingDir, 'new.txt'), 'new') + + replaceStandaloneInstall(stagingDir, installDir, backupDir) + + assert.equal(readFileSync(join(installDir, 'new.txt'), 'utf8'), 'new') + assert.equal(existsSync(join(installDir, 'old.txt')), false) + assert.equal(existsSync(backupDir), false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('Web standalone activation restores the old install when activation fails', () => { + const root = mkdtempSync(join(tmpdir(), 'clawpanel-standalone-rollback-')) + const installDir = join(root, 'install') + const missingStagingDir = join(root, 'missing-staging') + const backupDir = join(root, 'backup') + try { + mkdirSync(installDir) + writeFileSync(join(installDir, 'old.txt'), 'old') + + assert.throws(() => replaceStandaloneInstall(missingStagingDir, installDir, backupDir)) + + assert.equal(readFileSync(join(installDir, 'old.txt'), 'utf8'), 'old') + assert.equal(existsSync(backupDir), false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/tests/patch-gateway-origins.test.js b/tests/patch-gateway-origins.test.js index 275d426..6984ab7 100644 --- a/tests/patch-gateway-origins.test.js +++ b/tests/patch-gateway-origins.test.js @@ -6,7 +6,7 @@ const devApi = readFileSync(new URL('../scripts/dev-api.js', import.meta.url), ' const pairing = readFileSync(new URL('../src-tauri/src/commands/pairing.rs', import.meta.url), 'utf8') test('patchGatewayOrigins writes only allowedOrigins through merge path', () => { - const start = devApi.indexOf('function patchGatewayOrigins()') + const start = devApi.indexOf('function patchGatewayOrigins(') const end = devApi.indexOf('function readOpenclawConfigOptional()', start) const fn = start >= 0 && end > start ? devApi.slice(start, end) : '' @@ -18,7 +18,7 @@ test('patchGatewayOrigins writes only allowedOrigins through merge path', () => }) test('patch_gateway_origins writes only allowedOrigins patch in Rust', () => { - const start = pairing.indexOf('fn patch_gateway_origins()') + const start = pairing.indexOf('fn patch_gateway_origins(') const end = pairing.indexOf('#[tauri::command]', start) const fn = start >= 0 && end > start ? pairing.slice(start, end) : ''