fix(openclaw): harden 2026.7.1 installation

This commit is contained in:
晴天
2026-07-14 10:39:49 -07:00
parent 0802aa6d80
commit 46c9c7743b
9 changed files with 492 additions and 137 deletions

View File

@@ -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 / 代理问题)会给出明确的镜像与代理建议,不再只显示一句"安装失败"

View File

@@ -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 = {}

View File

@@ -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(&current, 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<PathBuf> {
}
}
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<String, String> {
#[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<String, String> {
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<String>, &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(&current_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"
);
}

View File

@@ -228,10 +228,10 @@ fn now_ms() -> u64 {
}
#[tauri::command]
pub fn auto_pair_device() -> Result<String, String> {
pub fn auto_pair_device(origin: Option<String>) -> Result<String, String> {
// 无论是否已配对,都确保 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<String, String> {
/// 将 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<String> = vec![
let mut required: Vec<String> = 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<String> = 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<String> {
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<bool, String> {
// 读取设备密钥

View File

@@ -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 }),

View File

@@ -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) {

View File

@@ -425,7 +425,7 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
// 第三步OpenClaw CLI
html += `
<div class="config-section" style="text-align:left;${nodeOk ? '' : 'opacity:0.65;pointer-events:none'}">
<div class="config-section" style="text-align:left">
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
${stepIcon(cliOk)} OpenClaw CLI
</div>
@@ -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'

View File

@@ -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 })
}
})

View File

@@ -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) : ''