mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-08-06 13:43:50 +08:00
fix(openclaw): harden 2026.7.1 installation
This commit is contained in:
@@ -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 / 代理问题)会给出明确的镜像与代理建议,不再只显示一句"安装失败"
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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<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(¤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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
// 读取设备密钥
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
119
tests/openclaw-install-safety.test.js
Normal file
119
tests/openclaw-install-safety.test.js
Normal 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 })
|
||||
}
|
||||
})
|
||||
@@ -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) : ''
|
||||
|
||||
|
||||
Reference in New Issue
Block a user