fix(release): harden pre-release stability gates

This commit is contained in:
晴天
2026-07-12 12:50:38 -07:00
parent b36a85e230
commit f514761d59
30 changed files with 2536 additions and 380 deletions

View File

@@ -29,6 +29,7 @@ static GW_GUARDIAN_ACTIVE: AtomicBool = AtomicBool::new(false);
/// 通知 guardian 停止的 flag
static GW_GUARDIAN_STOP: AtomicBool = AtomicBool::new(false);
static GW_STARTING: AtomicBool = AtomicBool::new(false);
static HERMES_INSTALLING: AtomicBool = AtomicBool::new(false);
/// 缓存 AppHandle 供 guardian 发送事件
static GW_APP_HANDLE: OnceLock<tauri::AppHandle> = OnceLock::new();
@@ -47,6 +48,21 @@ fn try_gateway_start_guard() -> Option<GatewayStartGuard> {
.map(|_| GatewayStartGuard)
}
struct HermesInstallGuard;
impl Drop for HermesInstallGuard {
fn drop(&mut self) {
HERMES_INSTALLING.store(false, Ordering::SeqCst);
}
}
fn try_hermes_install_guard() -> Option<HermesInstallGuard> {
HERMES_INSTALLING
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.ok()
.map(|_| HermesInstallGuard)
}
/// 获取 Gateway 的完整 URL当前本地未来可扩展为远程
fn hermes_gateway_custom_url() -> Option<String> {
super::read_panel_config_value()
@@ -1835,6 +1851,7 @@ pub async fn install_hermes(
method: String,
extras: Vec<String>,
) -> Result<String, String> {
let _install_guard = try_hermes_install_guard().ok_or("Hermes Agent 正在安装,请勿重复操作")?;
let _ = app.emit("hermes-install-log", "🚀 开始安装 Hermes Agent...");
let _ = app.emit("hermes-install-progress", 0u32);
@@ -3087,6 +3104,152 @@ fn merge_env_file(existing: &str, managed_keys: &[&str], new_pairs: &[(String, S
content
}
fn replace_hermes_files_transaction(entries: &[(PathBuf, String, bool)]) -> Result<(), String> {
let suffix = format!(
"{}-{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
);
let mut staged: Vec<(PathBuf, PathBuf, PathBuf, bool, bool)> = Vec::new();
for (file, content, _private) in entries {
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("创建 Hermes 配置目录失败: {e}"))?;
}
let temp = file.with_extension(format!("tmp-{suffix}"));
let backup = file.with_extension(format!("bak-sync-{suffix}"));
std::fs::write(&temp, content).map_err(|e| format!("写入临时配置失败: {e}"))?;
#[cfg(not(target_os = "windows"))]
if *private {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600))
.map_err(|e| format!("设置临时配置权限失败: {e}"))?;
}
staged.push((file.clone(), temp, backup, file.exists(), false));
}
let result = (|| {
for entry in &mut staged {
if entry.3 {
std::fs::rename(&entry.0, &entry.2)
.map_err(|e| format!("备份 Hermes 配置失败: {e}"))?;
}
std::fs::rename(&entry.1, &entry.0)
.map_err(|e| format!("提交 Hermes 配置失败: {e}"))?;
entry.4 = true;
}
Ok(())
})();
if let Err(error) = result {
for entry in staged.iter().rev() {
if entry.4 {
let _ = std::fs::remove_file(&entry.0);
}
if entry.3 && entry.2.exists() {
let _ = std::fs::rename(&entry.2, &entry.0);
}
let _ = std::fs::remove_file(&entry.1);
}
return Err(error);
}
for entry in &staged {
if entry.3 {
let _ = std::fs::remove_file(&entry.2);
}
}
Ok(())
}
fn sync_hermes_provider_files_at(
home: &Path,
provider: &str,
api_key: &str,
base_url: Option<&str>,
model: Option<&str>,
set_default: bool,
) -> Result<Value, String> {
use super::hermes_providers;
let provider = provider.trim();
let api_key = api_key.trim();
let config = hermes_providers::get_provider(provider)
.filter(|item| item.auth_type == hermes_providers::AUTH_API_KEY)
.ok_or_else(|| format!("Hermes Provider 不支持 API Key 同步: {provider}"))?;
let env_key = config
.api_key_env_vars
.first()
.copied()
.ok_or_else(|| format!("Hermes Provider 缺少 API Key 环境变量: {provider}"))?;
if api_key.is_empty() {
return Err("Hermes Provider API Key 不能为空".into());
}
let mut replace_keys: Vec<&str> = config.api_key_env_vars.to_vec();
if !config.base_url_env_var.is_empty() {
replace_keys.push(config.base_url_env_var);
}
let mut pairs = vec![(env_key.to_string(), api_key.to_string())];
if provider == "custom" {
if !replace_keys.contains(&"CUSTOM_API_KEY") {
replace_keys.push("CUSTOM_API_KEY");
}
pairs.push(("CUSTOM_API_KEY".into(), api_key.into()));
}
let normalized_base = base_url.unwrap_or_default().trim().trim_end_matches('/');
if !config.base_url_env_var.is_empty() && !normalized_base.is_empty() {
pairs.push((config.base_url_env_var.into(), normalized_base.into()));
}
let env_path = home.join(".env");
let current_env = std::fs::read_to_string(&env_path).unwrap_or_default();
let env_content = merge_env_file(&current_env, &replace_keys, &pairs);
let mut entries = vec![(env_path, env_content, true)];
if set_default {
let model = model.unwrap_or_default().trim();
if model.is_empty() {
return Err("设为默认模型时 model 不能为空".into());
}
let config_path = home.join("config.yaml");
let current_config = std::fs::read_to_string(&config_path).unwrap_or_else(|_| {
"platform_toolsets:\n api_server:\n - hermes-api-server\nterminal:\n backend: local\nplatforms:\n api_server:\n enabled: true\n".into()
});
let base_url_line = if !normalized_base.is_empty() && config.base_url_env_var.is_empty() {
format!(" base_url: {normalized_base}\n")
} else {
String::new()
};
let provider_line = format!(" provider: {provider}\n");
let config_content =
merge_hermes_config_yaml(&current_config, model, &base_url_line, &provider_line);
entries.push((config_path, config_content, false));
}
replace_hermes_files_transaction(&entries)?;
Ok(serde_json::json!({ "providerId": provider, "envKey": env_key }))
}
#[tauri::command]
pub fn hermes_sync_provider(
provider: String,
api_key: String,
base_url: Option<String>,
model: Option<String>,
set_default: bool,
) -> Result<Value, String> {
sync_hermes_provider_files_at(
&hermes_home(),
&provider,
&api_key,
base_url.as_deref(),
model.as_deref(),
set_default,
)
}
// ---------------------------------------------------------------------------
// Hermes 渠道配置 — 读写 ~/.hermes/config.yaml 的 platforms.<platform>
// 并同步 Hermes 运行时仍会读取的 .env 变量。
@@ -14136,6 +14299,29 @@ pub async fn hermes_health_check() -> Result<Value, String> {
}
}
#[tauri::command]
pub async fn hermes_probe_gateway(url: String) -> Result<Value, String> {
let normalized = url.trim().trim_end_matches('/');
let parsed = reqwest::Url::parse(normalized).map_err(|e| format!("Gateway URL 无效: {e}"))?;
if parsed.scheme() != "http" && parsed.scheme() != "https" {
return Err("Gateway URL 仅支持 HTTP/HTTPS".into());
}
let client = hermes_gateway_http_client(std::time::Duration::from_secs(5))
.map_err(|e| format!("HTTP 客户端创建失败: {e}"))?;
let response = client
.get(format!("{normalized}/health"))
.send()
.await
.map_err(|e| format!("Gateway 不可达: {e}"))?;
if !response.status().is_success() {
return Err(format!("Gateway 返回 HTTP {}", response.status()));
}
response
.json()
.await
.map_err(|e| format!("Gateway 响应无效: {e}"))
}
// ---------------------------------------------------------------------------
// hermes_capabilities — 探测 Gateway 暴露的 API 能力描述GET /v1/capabilities
//
@@ -25981,3 +26167,84 @@ platforms:
assert!(err.contains("display.platforms.telegram.streaming"));
}
}
#[cfg(test)]
mod hermes_provider_sync_tests {
use super::sync_hermes_provider_files_at;
use std::path::PathBuf;
fn temp_home(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"clawpanel-hermes-provider-sync-{tag}-{}",
std::process::id()
))
}
#[test]
fn provider_sync_preserves_other_provider_credentials_and_updates_target() {
let home = temp_home("preserve");
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(&home).unwrap();
std::fs::write(
home.join(".env"),
"ANTHROPIC_API_KEY=keep-me\nOPENAI_API_KEY=old\nCUSTOM_FLAG=keep\n",
)
.unwrap();
std::fs::write(
home.join("config.yaml"),
"model:\n default: old-model\n provider: anthropic\nlogging:\n level: INFO\n",
)
.unwrap();
sync_hermes_provider_files_at(
&home,
"custom",
"sk-new",
Some("https://gateway.example/v1"),
Some("gpt-test"),
true,
)
.unwrap();
let env = std::fs::read_to_string(home.join(".env")).unwrap();
assert!(env.contains("ANTHROPIC_API_KEY=keep-me"));
assert!(env.contains("CUSTOM_FLAG=keep"));
assert!(env.contains("OPENAI_API_KEY=sk-new"));
assert!(env.contains("CUSTOM_API_KEY=sk-new"));
assert!(env.contains("OPENAI_BASE_URL=https://gateway.example/v1"));
let config = std::fs::read_to_string(home.join("config.yaml")).unwrap();
assert!(config.contains("default: gpt-test"));
assert!(config.contains("provider: custom"));
assert!(config.contains("level: INFO"));
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn provider_sync_without_default_keeps_model_config_unchanged() {
let home = temp_home("no-default");
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(&home).unwrap();
let original = "model:\n default: old-model\n provider: anthropic\n";
std::fs::write(home.join("config.yaml"), original).unwrap();
sync_hermes_provider_files_at(
&home,
"anthropic",
"sk-ant",
None,
Some("claude-test"),
false,
)
.unwrap();
assert_eq!(
std::fs::read_to_string(home.join("config.yaml")).unwrap(),
original
);
let env = std::fs::read_to_string(home.join(".env")).unwrap();
assert!(env.contains("ANTHROPIC_API_KEY=sk-ant"));
let _ = std::fs::remove_dir_all(&home);
}
}

View File

@@ -147,6 +147,7 @@ const P_ZAI: HermesProvider = HermesProvider {
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[
"glm-5.2",
"glm-5.1",
"glm-5",
"glm-5v-turbo",
@@ -169,6 +170,7 @@ const P_KIMI_CODING: HermesProvider = HermesProvider {
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[
"kimi-k2.7-code",
"kimi-for-coding",
"kimi-k2.6",
"kimi-k2.5",
@@ -190,6 +192,7 @@ const P_KIMI_CODING_CN: HermesProvider = HermesProvider {
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[
"kimi-k2.7-code",
"kimi-for-coding",
"kimi-k2.6",
"kimi-k2.5",
@@ -271,8 +274,10 @@ const P_ALIBABA: HermesProvider = HermesProvider {
"qwen3.5-plus",
"qwen3-coder-plus",
"qwen3-coder-next",
"glm-5.2",
"glm-5",
"glm-4.7",
"kimi-k2.7-code",
"kimi-k2.5",
"MiniMax-M2.5",
],

View File

@@ -1,8 +1,10 @@
use base64::{engine::general_purpose, Engine as _};
use futures_util::StreamExt;
use serde_json::{json, Map, Value};
use std::path::{Component, Path, PathBuf};
use std::sync::Mutex;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
/// media-jobs.json 的读改写锁:并发轮询/写入时防止互相覆盖
static MEDIA_JOBS_LOCK: Mutex<()> = Mutex::new(());
@@ -1029,6 +1031,29 @@ fn guess_mime_from_ext(ext: &str, kind: &str) -> &'static str {
}
}
fn media_content_length_exceeds_limit(
headers: &reqwest::header::HeaderMap,
) -> Result<bool, String> {
let Some(value) = headers.get(reqwest::header::CONTENT_LENGTH) else {
return Ok(false);
};
let Ok(raw) = value.to_str() else {
return Ok(false);
};
let Ok(size) = raw.trim().parse::<u64>() else {
return Ok(false);
};
Ok(size > MAX_ASSET_BYTES)
}
fn ensure_media_content_length_allowed(headers: &reqwest::header::HeaderMap) -> Result<(), String> {
if media_content_length_exceeds_limit(headers)? {
Err("媒体文件超过 512MB已停止保存".into())
} else {
Ok(())
}
}
fn relative_asset_path(_kind: &str, job_id: &str, index: usize, ext: &str) -> PathBuf {
PathBuf::from("assets")
.join(chrono::Utc::now().format("%Y").to_string())
@@ -1072,14 +1097,20 @@ async fn write_asset_bytes(
/// 仅当资产 URL 与服务商 Base URL 同主机同端口时才允许携带 API Key
/// 防止服务商响应中混入第三方 URL 后把密钥发给任意主机
fn asset_url_same_host(url: &str, base_url: &str) -> bool {
fn asset_url_same_origin(url: &str, base_url: &str) -> bool {
let (Ok(asset), Ok(base)) = (reqwest::Url::parse(url), reqwest::Url::parse(base_url)) else {
return false;
};
asset.host_str().map(str::to_ascii_lowercase) == base.host_str().map(str::to_ascii_lowercase)
asset.scheme().eq_ignore_ascii_case(base.scheme())
&& asset.host_str().map(str::to_ascii_lowercase)
== base.host_str().map(str::to_ascii_lowercase)
&& asset.port_or_known_default() == base.port_or_known_default()
}
fn should_retry_asset_without_auth(status: reqwest::StatusCode, same_host: bool) -> bool {
same_host && matches!(status.as_u16(), 401 | 403)
}
async fn download_asset_to_media_root(
client: &reqwest::Client,
provider: &MediaProviderConfig,
@@ -1088,18 +1119,73 @@ async fn download_asset_to_media_root(
job_id: &str,
index: usize,
) -> Result<Value, String> {
let mut request = client.get(url);
if asset_url_same_host(url, &provider.base_url) {
request = request.bearer_auth(&provider.api_key);
}
let resp = request
.send()
.await
.map_err(|e| format!("下载媒体资产失败: {e}"))?;
let (resp, final_url) = fetch_media_asset_response(client, provider, url).await?;
if !resp.status().is_success() {
return Err(format!("下载媒体资产失败: HTTP {}", resp.status()));
}
let mime_owned = resp
stream_response_to_asset(
resp,
kind,
job_id,
index,
Some(&final_url),
provider.timeout_seconds,
)
.await
}
async fn fetch_media_asset_response(
client: &reqwest::Client,
provider: &MediaProviderConfig,
url: &str,
) -> Result<(reqwest::Response, String), String> {
let mut current = reqwest::Url::parse(url).map_err(|e| format!("媒体资产 URL 无效: {e}"))?;
let mut retry_without_auth = false;
for redirects in 0..=5 {
let same_origin = asset_url_same_origin(current.as_str(), &provider.base_url);
let send_auth = same_origin && !retry_without_auth && !provider.api_key.is_empty();
let mut request = client.get(current.clone());
if send_auth {
request = request.bearer_auth(&provider.api_key);
}
let response = request
.send()
.await
.map_err(|e| format!("下载媒体资产失败: {e}"))?;
if response.status().is_redirection() {
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
.ok_or("媒体资产重定向缺少 Location")?;
if redirects == 5 {
return Err("媒体资产重定向次数过多".into());
}
current = current
.join(location)
.map_err(|e| format!("媒体资产重定向 URL 无效: {e}"))?;
retry_without_auth = false;
continue;
}
if send_auth && should_retry_asset_without_auth(response.status(), same_origin) {
retry_without_auth = true;
continue;
}
return Ok((response, current.to_string()));
}
Err("媒体资产重定向次数过多".into())
}
async fn stream_response_to_asset(
resp: reqwest::Response,
kind: &str,
job_id: &str,
index: usize,
source_url: Option<&str>,
timeout_seconds: u64,
) -> Result<Value, String> {
ensure_media_content_length_allowed(resp.headers())?;
let mime = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
@@ -1111,11 +1197,79 @@ async fn download_asset_to_media_root(
}
})
.to_string();
let bytes = resp
.bytes()
.await
.map_err(|e| format!("读取媒体资产失败: {e}"))?;
write_asset_bytes(kind, job_id, index, bytes.as_ref(), &mime_owned, Some(url)).await
let cfg = read_media_config_private();
let root = ensure_media_output_root_from_config(&cfg)?;
let relative = relative_asset_path(
kind,
job_id,
index,
asset_ext_from_content_type(&mime, kind),
);
let target = root.join(&relative);
if let Some(parent) = target.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("创建媒体资产目录失败: {e}"))?;
}
let temp = target.with_extension(format!(
"{}.part-{}-{}",
target
.extension()
.and_then(|value| value.to_str())
.unwrap_or("asset"),
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
));
let operation = async {
let mut file = tokio::fs::File::create(&temp)
.await
.map_err(|e| format!("创建媒体资产临时文件失败: {e}"))?;
let mut stream = resp.bytes_stream();
let mut total = 0u64;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("读取媒体资产失败: {e}"))?;
total = total.saturating_add(chunk.len() as u64);
if total > MAX_ASSET_BYTES {
return Err("媒体文件超过 512MB已停止保存".into());
}
file.write_all(&chunk)
.await
.map_err(|e| format!("保存媒体资产失败: {e}"))?;
}
file.flush()
.await
.map_err(|e| format!("刷新媒体资产失败: {e}"))?;
drop(file);
if target.exists() {
tokio::fs::remove_file(&target)
.await
.map_err(|e| format!("替换媒体资产失败: {e}"))?;
}
tokio::fs::rename(&temp, &target)
.await
.map_err(|e| format!("提交媒体资产失败: {e}"))?;
Ok::<u64, String>(total)
};
let result = tokio::time::timeout(Duration::from_secs(timeout_seconds.max(1)), operation).await;
let bytes = match result {
Ok(Ok(bytes)) => bytes,
Ok(Err(error)) => {
let _ = tokio::fs::remove_file(&temp).await;
return Err(error);
}
Err(_) => {
let _ = tokio::fs::remove_file(&temp).await;
return Err("媒体资产下载超时".into());
}
};
Ok(json!({
"kind": kind,
"path": relative.to_string_lossy().replace('\\', "/"),
"root": root.to_string_lossy(),
"mime": mime,
"bytes": bytes,
"sourceUrl": source_url.unwrap_or("")
}))
}
async fn download_openai_video_content(
@@ -1129,26 +1283,19 @@ async fn download_openai_video_content(
&provider.base_url,
&format!("/videos/{provider_task_id}/content"),
);
let resp = client
.get(endpoint)
.bearer_auth(&provider.api_key)
.send()
.await
.map_err(|e| format!("下载视频内容失败: {e}"))?;
let (resp, final_url) = fetch_media_asset_response(client, provider, &endpoint).await?;
if !resp.status().is_success() {
return Err(format!("下载视频内容失败: HTTP {}", resp.status()));
}
let mime = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("video/mp4")
.to_string();
let bytes = resp
.bytes()
.await
.map_err(|e| format!("读取视频内容失败: {e}"))?;
write_asset_bytes("video", job_id, index, bytes.as_ref(), &mime, None).await
stream_response_to_asset(
resp,
"video",
job_id,
index,
Some(&final_url),
provider.timeout_seconds,
)
.await
}
fn collect_image_outputs(value: &Value) -> Vec<Value> {
@@ -1214,7 +1361,7 @@ fn sanitize_provider_error(raw: &str, api_key: &str) -> String {
}
fn media_http_client(timeout_seconds: u64) -> Result<reqwest::Client, String> {
super::build_http_client_no_proxy(
super::build_http_client_no_proxy_no_redirect(
Duration::from_secs(timeout_seconds),
Some("ClawPanel Media"),
)
@@ -1955,6 +2102,58 @@ mod tests {
assert_eq!(provider_status_to_job_status("cancelled"), "canceled");
}
#[test]
fn same_host_asset_download_retries_without_auth_on_auth_rejection() {
assert!(should_retry_asset_without_auth(
reqwest::StatusCode::UNAUTHORIZED,
true
));
assert!(should_retry_asset_without_auth(
reqwest::StatusCode::FORBIDDEN,
true
));
assert!(!should_retry_asset_without_auth(
reqwest::StatusCode::UNAUTHORIZED,
false
));
assert!(!should_retry_asset_without_auth(
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
true
));
}
#[test]
fn asset_auth_requires_matching_scheme_host_and_port() {
assert!(asset_url_same_origin(
"https://api.example.com/v1/asset",
"https://api.example.com/v1"
));
assert!(!asset_url_same_origin(
"http://api.example.com/v1/asset",
"https://api.example.com/v1"
));
assert!(!asset_url_same_origin(
"https://api.example.com:8443/asset",
"https://api.example.com/v1"
));
}
#[test]
fn media_download_rejects_oversized_content_length_before_buffering() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::CONTENT_LENGTH,
reqwest::header::HeaderValue::from_static("536870913"),
);
assert!(media_content_length_exceeds_limit(&headers).unwrap());
headers.insert(
reqwest::header::CONTENT_LENGTH,
reqwest::header::HeaderValue::from_static("536870912"),
);
assert!(!media_content_length_exceeds_limit(&headers).unwrap());
}
#[test]
fn extracts_media_models_from_common_response_shapes() {
let openai = json!({

View File

@@ -366,7 +366,7 @@ pub fn build_http_client(
timeout: Duration,
user_agent: Option<&str>,
) -> Result<reqwest::Client, String> {
build_http_client_opt(timeout, user_agent, true)
build_http_client_opt(timeout, user_agent, true, true)
}
/// 构建模型请求用的 HTTP 客户端
@@ -378,19 +378,33 @@ pub fn build_http_client_no_proxy(
let use_proxy = read_panel_config_value()
.and_then(|v| v.get("networkProxy")?.get("proxyModelRequests")?.as_bool())
.unwrap_or(false);
build_http_client_opt(timeout, user_agent, use_proxy)
build_http_client_opt(timeout, user_agent, use_proxy, true)
}
pub fn build_http_client_no_proxy_no_redirect(
timeout: Duration,
user_agent: Option<&str>,
) -> Result<reqwest::Client, String> {
let use_proxy = read_panel_config_value()
.and_then(|v| v.get("networkProxy")?.get("proxyModelRequests")?.as_bool())
.unwrap_or(false);
build_http_client_opt(timeout, user_agent, use_proxy, false)
}
fn build_http_client_opt(
timeout: Duration,
user_agent: Option<&str>,
use_proxy: bool,
follow_redirects: bool,
) -> Result<reqwest::Client, String> {
let mut builder = reqwest::Client::builder()
.timeout(timeout)
.gzip(true)
.brotli(true)
.deflate(true);
if !follow_redirects {
builder = builder.redirect(reqwest::redirect::Policy::none());
}
if let Some(ua) = user_agent {
builder = builder.user_agent(ua);
}

View File

@@ -369,8 +369,43 @@ fn path_is_inside_or_same(path: &Path, base: &Path) -> bool {
crate::utils::path_is_inside_or_same(path, base)
}
fn normalize_migration_path(path: &Path, label: &str) -> Result<PathBuf, String> {
crate::utils::canonicalize_path_for_safety(path, label)
}
fn metadata_is_link_or_reparse(metadata: &std::fs::Metadata) -> bool {
if metadata.file_type().is_symlink() {
return true;
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
false
}
fn reject_link_or_reparse_root(path: &Path, label: &str) -> Result<(), String> {
if let Ok(metadata) = std::fs::symlink_metadata(path) {
if metadata_is_link_or_reparse(&metadata) {
return Err(format!(
"{label}不能是链接或 reparse point: {}",
path.display()
));
}
}
Ok(())
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> {
if !src.is_dir() {
let source_metadata = std::fs::symlink_metadata(src)
.map_err(|e| format!("读取源目录元数据 {} 失败: {e}", src.display()))?;
if metadata_is_link_or_reparse(&source_metadata) {
return Err(format!("拒绝复制链接或 reparse point: {}", src.display()));
}
if !source_metadata.is_dir() {
return Err(format!("源目录不存在: {}", src.display()));
}
std::fs::create_dir_all(dst).map_err(|e| format!("创建目录 {} 失败: {e}", dst.display()))?;
@@ -380,9 +415,14 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> {
let entry = entry.map_err(|e| format!("读取目录项失败: {e}"))?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
let meta = entry
.metadata()
let meta = std::fs::symlink_metadata(&src_path)
.map_err(|e| format!("读取元数据 {} 失败: {e}", src_path.display()))?;
if metadata_is_link_or_reparse(&meta) {
return Err(format!(
"拒绝复制链接或 reparse point: {}",
src_path.display()
));
}
if meta.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else if meta.is_file() {
@@ -402,16 +442,192 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> {
Ok(())
}
struct StagingDir {
path: PathBuf,
active: bool,
}
impl StagingDir {
fn create_for(target: &Path) -> Result<Self, String> {
let parent = target
.parent()
.ok_or_else(|| format!("目标目录缺少父目录: {}", target.display()))?;
std::fs::create_dir_all(parent)
.map_err(|e| format!("创建目标父目录 {} 失败: {e}", parent.display()))?;
let name = target
.file_name()
.map(|value| value.to_string_lossy().to_string())
.unwrap_or_else(|| "portable".into());
for attempt in 0..100_u32 {
let candidate = parent.join(format!("{name}.staging-{}-{attempt}", std::process::id()));
if !candidate.exists() {
std::fs::create_dir(&candidate)
.map_err(|e| format!("创建 staging 目录 {} 失败: {e}", candidate.display()))?;
return Ok(Self {
path: candidate,
active: true,
});
}
}
Err(format!("无法为 {} 分配 staging 目录", target.display()))
}
fn disarm(&mut self) {
self.active = false;
}
}
impl Drop for StagingDir {
fn drop(&mut self) {
if self.active {
let _ = std::fs::remove_dir_all(&self.path);
}
}
}
fn path_looks_absolute(value: &str) -> bool {
let bytes = value.as_bytes();
Path::new(value).is_absolute()
|| value.starts_with('/')
|| value.starts_with("\\\\")
|| (bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'\\' | b'/'))
}
fn collect_external_paths_from_value(
value: &Value,
root: &Path,
file_label: &str,
field_path: &str,
warnings: &mut Vec<String>,
) {
match value {
Value::Object(map) => {
for (key, child) in map {
let child_path = if field_path.is_empty() {
key.clone()
} else {
format!("{field_path}.{key}")
};
let lower = key.to_ascii_lowercase();
if (lower == "workspace" || lower == "path" || lower.ends_with("path"))
&& child.as_str().is_some_and(path_looks_absolute)
{
let raw = child.as_str().unwrap_or_default();
let candidate = Path::new(raw);
let is_inside = candidate.is_absolute()
&& normalize_migration_path(candidate, "配置绝对路径")
.map(|resolved| path_is_inside_or_same(&resolved, root))
.unwrap_or(false);
if !is_inside {
warnings.push(format!("external-absolute-path:{file_label}:{child_path}"));
}
}
collect_external_paths_from_value(child, root, file_label, &child_path, warnings);
}
}
Value::Array(items) => {
for (index, child) in items.iter().enumerate() {
collect_external_paths_from_value(
child,
root,
file_label,
&format!("{field_path}[{index}]"),
warnings,
);
}
}
_ => {}
}
}
fn collect_external_path_warnings(root: &Path, warnings: &mut Vec<String>) {
fn visit(dir: &Path, root: &Path, warnings: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(metadata) = std::fs::symlink_metadata(&path) else {
continue;
};
if metadata_is_link_or_reparse(&metadata) {
continue;
}
if metadata.is_dir() {
visit(&path, root, warnings);
continue;
}
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let parsed = match extension.as_str() {
"json" => std::fs::read_to_string(&path)
.ok()
.and_then(|text| serde_json::from_str::<Value>(&text).ok()),
"yaml" | "yml" => std::fs::read_to_string(&path).ok().and_then(|text| {
serde_yaml::from_str::<serde_yaml::Value>(&text)
.ok()
.and_then(|yaml| serde_json::to_value(yaml).ok())
}),
_ => None,
};
if let Some(value) = parsed {
let label = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
collect_external_paths_from_value(&value, root, &label, "", warnings);
}
}
}
if root.is_dir() {
visit(root, root, warnings);
warnings.sort();
warnings.dedup();
}
}
fn reject_overlapping_roots(target: &Path, sources: &[(&Path, &str)]) -> Result<(), String> {
for (source, label) in sources {
if source.exists()
&& (path_is_inside_or_same(target, source) || path_is_inside_or_same(source, target))
{
return Err(format!("目标目录与{label}重叠,无法迁移"));
}
}
Ok(())
}
fn migrate_to_portable_impl(
target_root: &Path,
panel_config: Option<Value>,
source_openclaw_dir: &Path,
source_engine_dir: Option<&Path>,
source_hermes_home: Option<&Path>,
include_current_app: bool,
) -> Result<Value, String> {
if target_root.as_os_str().is_empty() {
return Err("请选择便携模式目标目录".into());
reject_link_or_reparse_root(source_openclaw_dir, "OpenClaw 源目录")?;
if let Some(path) = source_engine_dir {
reject_link_or_reparse_root(path, "OpenClaw 引擎源目录")?;
}
if let Some(path) = source_hermes_home {
reject_link_or_reparse_root(path, "Hermes 源目录")?;
}
let target_root = normalize_migration_path(target_root, "便携模式目标目录")?;
let source_openclaw_dir = normalize_migration_path(source_openclaw_dir, "OpenClaw 源目录")?;
let source_engine_dir = source_engine_dir
.map(|path| normalize_migration_path(path, "OpenClaw 引擎源目录"))
.transpose()?;
let source_hermes_home = source_hermes_home
.map(|path| normalize_migration_path(path, "Hermes 源目录"))
.transpose()?;
if target_root.is_file() {
return Err("目标路径是文件,请选择目录".into());
}
@@ -420,35 +636,35 @@ fn migrate_to_portable_impl(
if portable_json.exists() {
return Err("目标目录已存在 portable.json请选择空目录或新的便携目录".into());
}
if target_root.exists() && needs_backup(&target_root) {
return Err("目标目录不是空目录,请选择空目录或新的便携目录".into());
}
let data_dir = target_root.join("data");
let mut sources = vec![(source_openclaw_dir.as_path(), "当前 OpenClaw 配置目录")];
if let Some(path) = source_engine_dir.as_deref() {
sources.push((path, "当前 OpenClaw 引擎目录"));
}
if let Some(path) = source_hermes_home.as_deref() {
sources.push((path, "当前 Hermes 数据目录"));
}
reject_overlapping_roots(&target_root, &sources)?;
let target_existed_empty = target_root.is_dir();
let mut staging = StagingDir::create_for(&target_root)?;
let staging_root = staging.path.clone();
let data_dir = staging_root.join("data");
let panel_dir = data_dir.join("clawpanel");
let portable_panel_config = panel_dir.join("clawpanel.json");
let portable_openclaw_dir = data_dir.join("openclaw");
let portable_hermes_home = data_dir.join("hermes");
let engines_openclaw_dir = target_root.join("engines").join("openclaw");
let engines_hermes_dir = target_root.join("engines").join("hermes");
let runtimes_node_dir = target_root.join("runtimes").join("node");
let runtimes_uv_dir = target_root.join("runtimes").join("uv");
if source_openclaw_dir.is_dir()
&& path_is_inside_or_same(&portable_openclaw_dir, source_openclaw_dir)
{
return Err("目标目录不能放在当前 OpenClaw 配置目录内部,避免递归复制".into());
}
if let Some(engine_dir) = source_engine_dir {
if engine_dir.is_dir() && path_is_inside_or_same(&engines_openclaw_dir, engine_dir) {
return Err("目标目录不能放在当前 OpenClaw 引擎目录内部,避免递归复制".into());
}
}
if let Some(hermes_home) = source_hermes_home {
if hermes_home.is_dir() && path_is_inside_or_same(&portable_hermes_home, hermes_home) {
return Err("目标目录不能放在当前 Hermes 数据目录内部,避免递归复制".into());
}
}
let engines_openclaw_dir = staging_root.join("engines").join("openclaw");
let engines_hermes_dir = staging_root.join("engines").join("hermes");
let runtimes_node_dir = staging_root.join("runtimes").join("node");
let runtimes_uv_dir = staging_root.join("runtimes").join("uv");
for dir in [
target_root,
&staging_root,
&data_dir,
&panel_dir,
&portable_openclaw_dir,
@@ -462,12 +678,6 @@ fn migrate_to_portable_impl(
.map_err(|e| format!("创建目录 {} 失败: {e}", dir.display()))?;
}
let manifest = portable_manifest();
let manifest_text = serde_json::to_string_pretty(&manifest)
.map_err(|e| format!("序列化 portable.json 失败: {e}"))?;
std::fs::write(&portable_json, manifest_text)
.map_err(|e| format!("写入 portable.json 失败: {e}"))?;
let (portable_panel, removed_panel_keys) = sanitized_panel_config(panel_config);
let panel_text = serde_json::to_string_pretty(&portable_panel)
.map_err(|e| format!("序列化 clawpanel.json 失败: {e}"))?;
@@ -476,7 +686,8 @@ fn migrate_to_portable_impl(
let mut warnings = Vec::new();
let copied_openclaw = if source_openclaw_dir.is_dir() {
copy_dir_recursive(source_openclaw_dir, &portable_openclaw_dir)?;
collect_external_path_warnings(&source_openclaw_dir, &mut warnings);
copy_dir_recursive(&source_openclaw_dir, &portable_openclaw_dir)?;
true
} else {
warnings.push("openclaw-source-missing".to_string());
@@ -484,7 +695,7 @@ fn migrate_to_portable_impl(
};
let mut copied_engine = false;
if let Some(engine_dir) = source_engine_dir {
if let Some(engine_dir) = source_engine_dir.as_deref() {
if engine_dir.is_dir() {
copy_dir_recursive(engine_dir, &engines_openclaw_dir)?;
copied_engine = true;
@@ -492,14 +703,58 @@ fn migrate_to_portable_impl(
}
let mut copied_hermes_home = false;
if let Some(hermes_home) = source_hermes_home {
if let Some(hermes_home) = source_hermes_home.as_deref() {
if hermes_home.is_dir() {
collect_external_path_warnings(hermes_home, &mut warnings);
copy_dir_recursive(hermes_home, &portable_hermes_home)?;
copied_hermes_home = true;
}
}
Ok(json!({
let mut app_copied = false;
let mut portable_app_path = None;
if include_current_app {
match copy_current_app_binary(&staging_root) {
Ok(staged_path) => {
let file_name = staged_path
.file_name()
.ok_or_else(|| "staging 应用路径无文件名".to_string())?;
app_copied = true;
portable_app_path = Some(target_root.join(file_name));
}
Err(error) if cfg!(not(windows)) => {
warnings.push(format!("app-copy-unsupported:{error}"));
}
Err(error) => return Err(error),
}
}
let manifest = portable_manifest();
let manifest_text = serde_json::to_string_pretty(&manifest)
.map_err(|e| format!("序列化 portable.json 失败: {e}"))?;
std::fs::write(staging_root.join("portable.json"), manifest_text)
.map_err(|e| format!("写入 staging portable.json 失败: {e}"))?;
if target_existed_empty {
std::fs::remove_dir(&target_root)
.map_err(|e| format!("移除空目标目录 {} 失败: {e}", target_root.display()))?;
}
if let Err(error) = std::fs::rename(&staging_root, &target_root) {
if target_existed_empty {
let _ = std::fs::create_dir_all(&target_root);
}
return Err(format!("提交便携迁移 staging 失败: {error}"));
}
staging.disarm();
let data_dir = target_root.join("data");
let portable_panel_config = data_dir.join("clawpanel").join("clawpanel.json");
let portable_openclaw_dir = data_dir.join("openclaw");
let portable_hermes_home = data_dir.join("hermes");
let engines_openclaw_dir = target_root.join("engines").join("openclaw");
let engines_hermes_dir = target_root.join("engines").join("hermes");
let mut report = json!({
"root": target_root.to_string_lossy(),
"portableJson": portable_json.to_string_lossy(),
"panelConfigPath": portable_panel_config.to_string_lossy(),
@@ -514,7 +769,18 @@ fn migrate_to_portable_impl(
"needsHermesInstall": true,
"removedPanelKeys": removed_panel_keys,
"warnings": warnings,
}))
});
if include_current_app {
let object = report.as_object_mut().expect("migration report object");
object.insert("appCopied".into(), Value::Bool(app_copied));
object.insert(
"portableAppPath".into(),
portable_app_path
.map(|path| Value::String(path.to_string_lossy().to_string()))
.unwrap_or(Value::Null),
);
}
Ok(report)
}
/// 目录存在且非空才值得备份;空目录直接复用,避免产生噪音备份
@@ -536,6 +802,94 @@ fn backup_sibling_path(path: &Path, timestamp: &str) -> PathBuf {
path.with_file_name(format!("{name}.backup-{timestamp}"))
}
struct SwitchRecord {
target: PathBuf,
backup: Option<PathBuf>,
restore_empty_dir: bool,
}
fn remove_switched_target(path: &Path) -> Result<(), String> {
if !path.exists() {
return Ok(());
}
let metadata = std::fs::symlink_metadata(path)
.map_err(|e| format!("读取切换目标 {} 失败: {e}", path.display()))?;
if metadata.is_dir() {
std::fs::remove_dir_all(path)
.map_err(|e| format!("清理切换目标 {} 失败: {e}", path.display()))
} else {
std::fs::remove_file(path).map_err(|e| format!("清理切换目标 {} 失败: {e}", path.display()))
}
}
fn rollback_switch(record: &SwitchRecord) -> Result<(), String> {
remove_switched_target(&record.target)?;
if let Some(backup) = &record.backup {
std::fs::rename(backup, &record.target).map_err(|e| {
format!(
"恢复备份 {} -> {} 失败: {e}",
backup.display(),
record.target.display()
)
})?;
} else if record.restore_empty_dir {
std::fs::create_dir_all(&record.target)
.map_err(|e| format!("恢复空目录 {} 失败: {e}", record.target.display()))?;
}
Ok(())
}
fn switch_staged_directory(
staging: &mut StagingDir,
target: &Path,
timestamp: &str,
) -> Result<SwitchRecord, String> {
let restore_empty_dir = target.is_dir() && !needs_backup(target);
let backup = if target.exists() && needs_backup(target) {
let backup = backup_sibling_path(target, timestamp);
if backup.exists() {
return Err(format!("备份路径已存在,请稍后重试: {}", backup.display()));
}
std::fs::rename(target, &backup)
.map_err(|e| format!("备份现有目录 {} 失败: {e}", target.display()))?;
Some(backup)
} else {
if restore_empty_dir {
std::fs::remove_dir(target)
.map_err(|e| format!("移除空目标目录 {} 失败: {e}", target.display()))?;
}
None
};
if let Err(error) = std::fs::rename(&staging.path, target) {
let restore_result = if let Some(backup) = &backup {
std::fs::rename(backup, target)
.map_err(|e| format!("恢复备份 {} 失败: {e}", backup.display()))
} else if restore_empty_dir {
std::fs::create_dir_all(target)
.map_err(|e| format!("恢复空目录 {} 失败: {e}", target.display()))
} else {
Ok(())
};
return match restore_result {
Ok(()) => Err(format!(
"切换 staging 到 {} 失败: {error}",
target.display()
)),
Err(restore_error) => Err(format!(
"切换 staging 到 {} 失败: {error}; {restore_error}",
target.display()
)),
};
}
staging.disarm();
Ok(SwitchRecord {
target: target.to_path_buf(),
backup,
restore_empty_dir,
})
}
/// 将便携数据迁移回本机默认位置migrate_to_portable 的反向)。
/// 语义:以 U 盘数据为准——本机已有数据先整体改名备份(.backup-<时间戳>
/// 再全新复制,避免新旧数据合并出难排查的混合状态。
@@ -547,61 +901,98 @@ fn migrate_to_local_impl(
target_openclaw_dir: &Path,
target_hermes_home: &Path,
) -> Result<Value, String> {
reject_link_or_reparse_root(source_openclaw_dir, "便携 OpenClaw 源目录")?;
reject_link_or_reparse_root(source_hermes_home, "便携 Hermes 源目录")?;
let source_openclaw_dir =
normalize_migration_path(source_openclaw_dir, "便携 OpenClaw 源目录")?;
let source_hermes_home = normalize_migration_path(source_hermes_home, "便携 Hermes 源目录")?;
let target_openclaw_dir =
normalize_migration_path(target_openclaw_dir, "本机 OpenClaw 目标目录")?;
let target_hermes_home = normalize_migration_path(target_hermes_home, "本机 Hermes 目标目录")?;
// 防呆:目标不能位于便携源内部或与其相同(自定义路径可能指回 U 盘)
if path_is_inside_or_same(target_openclaw_dir, source_openclaw_dir)
|| path_is_inside_or_same(source_openclaw_dir, target_openclaw_dir)
{
return Err("本机 OpenClaw 目录与便携目录重叠,无法迁移".into());
}
if path_is_inside_or_same(target_hermes_home, source_hermes_home)
|| path_is_inside_or_same(source_hermes_home, target_hermes_home)
{
return Err("本机 Hermes 目录与便携目录重叠,无法迁移".into());
}
reject_overlapping_roots(
&target_openclaw_dir,
&[
(&source_openclaw_dir, "便携 OpenClaw 目录"),
(&source_hermes_home, "便携 Hermes 目录"),
],
)?;
reject_overlapping_roots(
&target_hermes_home,
&[
(&source_openclaw_dir, "便携 OpenClaw 目录"),
(&source_hermes_home, "便携 Hermes 目录"),
],
)?;
let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string();
let mut backups: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let (panel, removed_keys) = sanitized_panel_config(source_panel_config);
// OpenClaw 数据(含面板级 clawpanel/ 子目录:模型渠道、媒体数据随之带回)
let mut copied_openclaw = false;
if source_openclaw_dir.is_dir() {
if target_openclaw_dir.exists() && needs_backup(target_openclaw_dir) {
let bak = backup_sibling_path(target_openclaw_dir, &timestamp);
std::fs::rename(target_openclaw_dir, &bak).map_err(|e| {
format!("备份本机 OpenClaw 数据失败(若本机 Gateway 正在运行请先停止): {e}")
})?;
backups.push(bak.to_string_lossy().to_string());
}
copy_dir_recursive(source_openclaw_dir, target_openclaw_dir)?;
copied_openclaw = true;
let mut openclaw_staging = if source_openclaw_dir.is_dir() {
let staging = StagingDir::create_for(&target_openclaw_dir)?;
collect_external_path_warnings(&source_openclaw_dir, &mut warnings);
copy_dir_recursive(&source_openclaw_dir, &staging.path)?;
let panel_text = serde_json::to_string_pretty(&panel)
.map_err(|e| format!("序列化 clawpanel.json 失败: {e}"))?;
std::fs::write(staging.path.join("clawpanel.json"), panel_text)
.map_err(|e| format!("写入 staging clawpanel.json 失败: {e}"))?;
Some(staging)
} else {
warnings.push("portable-openclaw-missing".into());
}
None
};
let mut hermes_staging = if source_hermes_home.is_dir() {
let staging = StagingDir::create_for(&target_hermes_home)?;
collect_external_path_warnings(&source_hermes_home, &mut warnings);
copy_dir_recursive(&source_hermes_home, &staging.path)?;
Some(staging)
} else {
None
};
// OpenClaw 数据(含面板级 clawpanel/ 子目录:模型渠道、媒体数据随之带回)
let mut openclaw_switch = None;
let copied_openclaw = if let Some(staging) = openclaw_staging.as_mut() {
let switched = switch_staged_directory(staging, &target_openclaw_dir, &timestamp)
.map_err(|e| format!("切换本机 OpenClaw 数据失败: {e}"))?;
if let Some(backup) = &switched.backup {
backups.push(backup.to_string_lossy().to_string());
}
openclaw_switch = Some(switched);
true
} else {
false
};
// Hermes 数据
let mut copied_hermes = false;
if source_hermes_home.is_dir() {
if target_hermes_home.exists() && needs_backup(target_hermes_home) {
let bak = backup_sibling_path(target_hermes_home, &timestamp);
std::fs::rename(target_hermes_home, &bak)
.map_err(|e| format!("备份本机 Hermes 数据失败: {e}"))?;
backups.push(bak.to_string_lossy().to_string());
let copied_hermes = if let Some(staging) = hermes_staging.as_mut() {
match switch_staged_directory(staging, &target_hermes_home, &timestamp) {
Ok(switched) => {
if let Some(backup) = &switched.backup {
backups.push(backup.to_string_lossy().to_string());
}
true
}
Err(error) => {
if let Some(switched) = &openclaw_switch {
if let Err(rollback_error) = rollback_switch(switched) {
return Err(format!(
"切换本机 Hermes 数据失败: {error}; 回滚 OpenClaw 失败: {rollback_error}"
));
}
}
return Err(format!("切换本机 Hermes 数据失败: {error}"));
}
}
copy_dir_recursive(source_hermes_home, target_hermes_home)?;
copied_hermes = true;
}
} else {
false
};
// 面板配置:写入本机 openclaw 目录下的 clawpanel.json
// 与正向迁移同理清洗绝对路径字段(便携配置里可能残留指向 U 盘的路径)
let (panel, removed_keys) = sanitized_panel_config(source_panel_config);
let target_panel_config = target_openclaw_dir.join("clawpanel.json");
std::fs::create_dir_all(target_openclaw_dir)
.map_err(|e| format!("创建本机数据目录失败: {e}"))?;
let panel_text = serde_json::to_string_pretty(&panel)
.map_err(|e| format!("序列化 clawpanel.json 失败: {e}"))?;
std::fs::write(&target_panel_config, panel_text)
.map_err(|e| format!("写入本机 clawpanel.json 失败: {e}"))?;
Ok(json!({
"openclawDir": target_openclaw_dir.to_string_lossy(),
@@ -648,6 +1039,7 @@ fn active_standalone_engine_dir() -> Option<PathBuf> {
None
}
#[cfg(windows)]
fn copy_current_app_binary(target_root: &Path) -> Result<PathBuf, String> {
let exe = std::env::current_exe().map_err(|e| format!("读取当前程序路径失败: {e}"))?;
let file_name = exe
@@ -666,6 +1058,11 @@ fn copy_current_app_binary(target_root: &Path) -> Result<PathBuf, String> {
Ok(target)
}
#[cfg(not(windows))]
fn copy_current_app_binary(_target_root: &Path) -> Result<PathBuf, String> {
Err("当前平台不支持将应用复制为单文件便携程序".into())
}
/// 将当前本机配置复制到一个新的便携目录。当前进程不会切换到便携模式;
/// 用户需要从目标目录里的程序重新启动,启动期才会读取 portable.json。
#[tauri::command]
@@ -677,45 +1074,43 @@ pub fn migrate_to_portable(target_root: String) -> Result<Value, String> {
if target_root.is_empty() {
return Err("请选择便携模式目标目录".into());
}
let target_root = PathBuf::from(target_root);
let target_root = normalize_migration_path(&PathBuf::from(target_root), "便携模式目标目录")?;
let source_openclaw_dir = crate::commands::openclaw_dir();
let engine_dir = active_standalone_engine_dir();
let mut report = migrate_to_portable_impl(
migrate_to_portable_impl(
&target_root,
crate::commands::read_panel_config_value(),
&source_openclaw_dir,
engine_dir.as_deref(),
Some(&crate::commands::hermes::hermes_home_path()),
)?;
match copy_current_app_binary(&target_root) {
Ok(path) => {
if let Some(obj) = report.as_object_mut() {
obj.insert("appCopied".into(), Value::Bool(true));
obj.insert(
"portableAppPath".into(),
Value::String(path.to_string_lossy().to_string()),
);
}
}
Err(err) => {
if let Some(obj) = report.as_object_mut() {
obj.insert("appCopied".into(), Value::Bool(false));
obj.insert("portableAppPath".into(), Value::Null);
if let Some(warnings) = obj.get_mut("warnings").and_then(|v| v.as_array_mut()) {
warnings.push(Value::String(format!("app-copy-failed:{err}")));
}
}
}
}
Ok(report)
true,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
fn create_directory_link(link: &Path, target: &Path) {
let status = std::process::Command::new("cmd")
.args([
"/C",
"mklink",
"/J",
&link.to_string_lossy(),
&target.to_string_lossy(),
])
.status()
.unwrap();
assert!(status.success(), "failed to create test junction");
}
#[cfg(unix)]
fn create_directory_link(link: &Path, target: &Path) {
std::os::unix::fs::symlink(target, link).unwrap();
}
fn temp_root(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"clawpanel-portable-test-{tag}-{}",
@@ -989,6 +1384,24 @@ mod tests {
let _ = std::fs::remove_dir_all(&usb);
}
#[test]
fn migrate_to_local_rejects_canonicalized_link_alias_overlap() {
let parent = temp_root("to-local-link-overlap");
let source = parent.join("portable-openclaw");
let alias = parent.join("source-alias");
let hermes_source = parent.join("portable-hermes");
let hermes_target = parent.join("local-hermes");
std::fs::create_dir_all(&source).unwrap();
std::fs::create_dir_all(&hermes_source).unwrap();
create_directory_link(&alias, &source);
let err = migrate_to_local_impl(&source, &hermes_source, None, &alias, &hermes_target)
.unwrap_err();
assert!(err.contains("重叠"), "unexpected error: {err}");
let _ = std::fs::remove_dir_all(&parent);
}
#[test]
fn migration_creates_portable_layout_and_sanitizes_host_paths() {
let target = temp_root("migrate-target");
@@ -1018,9 +1431,15 @@ mod tests {
let hermes_source = temp_root("migrate-hermes-source");
std::fs::write(hermes_source.join("config.yaml"), b"model: test\n").unwrap();
let report =
migrate_to_portable_impl(&target, Some(panel), &source, None, Some(&hermes_source))
.unwrap();
let report = migrate_to_portable_impl(
&target,
Some(panel),
&source,
None,
Some(&hermes_source),
false,
)
.unwrap();
assert!(target.join("portable.json").is_file());
assert!(target
@@ -1074,4 +1493,200 @@ mod tests {
let _ = std::fs::remove_dir_all(&source);
let _ = std::fs::remove_dir_all(&hermes_source);
}
#[test]
fn migration_rejects_parent_components_in_target_path() {
let base = temp_root("parent-target");
let source = base.join("source");
std::fs::create_dir_all(&source).unwrap();
std::fs::write(source.join("openclaw.json"), b"{}").unwrap();
let target = base.join("unused").join("..");
let err = migrate_to_portable_impl(&target, None, &source, None, None, false).unwrap_err();
assert!(err.contains(".."), "unexpected error: {err}");
assert!(!base.join("portable.json").exists());
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn copy_dir_recursive_rejects_directory_links() {
let source = temp_root("link-source");
let external = temp_root("link-external");
let target = temp_root("link-target");
std::fs::write(external.join("outside.txt"), b"outside").unwrap();
create_directory_link(&source.join("linked"), &external);
let err = copy_dir_recursive(&source, &target).unwrap_err();
assert!(
err.contains("链接") || err.contains("reparse"),
"unexpected error: {err}"
);
assert!(!target.join("linked").join("outside.txt").exists());
let _ = std::fs::remove_dir_all(&source);
let _ = std::fs::remove_dir_all(&external);
let _ = std::fs::remove_dir_all(&target);
}
#[test]
fn migration_rejects_link_used_as_source_root() {
let parent = temp_root("link-source-root");
let real_source = parent.join("real-source");
let source_link = parent.join("source-link");
let target = parent.join("portable");
std::fs::create_dir_all(&real_source).unwrap();
std::fs::write(real_source.join("outside.txt"), b"outside").unwrap();
create_directory_link(&source_link, &real_source);
let err =
migrate_to_portable_impl(&target, None, &source_link, None, None, false).unwrap_err();
assert!(
err.contains("链接") || err.contains("reparse"),
"unexpected error: {err}"
);
assert!(!target.exists());
let _ = std::fs::remove_dir_all(&parent);
}
#[test]
fn failed_forward_migration_leaves_no_manifest_or_staging() {
let parent = temp_root("forward-failure");
let source = parent.join("source");
let external = parent.join("external");
let target = parent.join("portable");
std::fs::create_dir_all(&source).unwrap();
std::fs::create_dir_all(&external).unwrap();
create_directory_link(&source.join("linked"), &external);
let err = migrate_to_portable_impl(&target, None, &source, None, None, false).unwrap_err();
assert!(
err.contains("链接") || err.contains("reparse"),
"unexpected error: {err}"
);
assert!(!target.join("portable.json").exists());
let staging_prefix = format!("{}.staging-", target.file_name().unwrap().to_string_lossy());
assert!(!std::fs::read_dir(&parent).unwrap().flatten().any(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with(&staging_prefix)
}));
let _ = std::fs::remove_dir_all(&parent);
}
#[test]
fn failed_reverse_copy_keeps_existing_target_in_place() {
let parent = temp_root("reverse-failure");
let source = parent.join("portable-openclaw");
let external = parent.join("external");
let target = parent.join("local-openclaw");
let hermes_source = parent.join("portable-hermes");
let hermes_target = parent.join("local-hermes");
for dir in [&source, &external, &target, &hermes_source] {
std::fs::create_dir_all(dir).unwrap();
}
std::fs::write(target.join("local-only.txt"), b"keep").unwrap();
create_directory_link(&source.join("linked"), &external);
let err = migrate_to_local_impl(&source, &hermes_source, None, &target, &hermes_target)
.unwrap_err();
assert!(
err.contains("链接") || err.contains("reparse"),
"unexpected error: {err}"
);
assert_eq!(
std::fs::read(target.join("local-only.txt")).unwrap(),
b"keep"
);
assert!(!std::fs::read_dir(&parent).unwrap().flatten().any(|entry| {
entry.file_name().to_string_lossy().contains(".backup-")
|| entry.file_name().to_string_lossy().contains(".staging-")
}));
let _ = std::fs::remove_dir_all(&parent);
}
#[test]
fn rollback_switch_restores_backup_after_later_switch_failure() {
let parent = temp_root("switch-rollback");
let target = parent.join("local-openclaw");
let backup = parent.join("local-openclaw.backup-test");
std::fs::create_dir_all(&target).unwrap();
std::fs::write(target.join("new.txt"), b"new").unwrap();
std::fs::create_dir_all(&backup).unwrap();
std::fs::write(backup.join("old.txt"), b"old").unwrap();
let record = SwitchRecord {
target: target.clone(),
backup: Some(backup.clone()),
restore_empty_dir: false,
};
rollback_switch(&record).unwrap();
assert!(target.join("old.txt").is_file());
assert!(!target.join("new.txt").exists());
assert!(!backup.exists());
let _ = std::fs::remove_dir_all(&parent);
}
#[test]
fn migration_warns_about_absolute_paths_outside_source_root() {
let parent = temp_root("external-path-warning");
let source = parent.join("source");
let target = parent.join("portable");
let outside = parent.join("outside-workspace");
std::fs::create_dir_all(&source).unwrap();
std::fs::write(
source.join("openclaw.json"),
serde_json::to_vec(&json!({
"agents": {
"defaults": { "workspace": outside },
"list": [{ "id": "main", "path": outside.join("agent") }]
}
}))
.unwrap(),
)
.unwrap();
let report = migrate_to_portable_impl(&target, None, &source, None, None, false).unwrap();
let warnings = report["warnings"].as_array().unwrap();
assert!(warnings.iter().any(|warning| warning
.as_str()
.is_some_and(|text| text.starts_with("external-absolute-path:"))));
let _ = std::fs::remove_dir_all(&parent);
}
#[test]
fn migration_reports_platform_app_copy_truthfully() {
let parent = temp_root("app-copy-platform");
let source = parent.join("source");
let target = parent.join("portable");
std::fs::create_dir_all(&source).unwrap();
let report = migrate_to_portable_impl(&target, None, &source, None, None, true).unwrap();
if cfg!(windows) {
assert_eq!(report["appCopied"], true);
let app_path = PathBuf::from(report["portableAppPath"].as_str().unwrap());
assert!(app_path.is_file());
} else {
assert_eq!(report["appCopied"], false);
assert!(report["portableAppPath"].is_null());
assert!(report["warnings"]
.as_array()
.unwrap()
.iter()
.any(|warning| {
warning
.as_str()
.is_some_and(|text| text.starts_with("app-copy-unsupported:"))
}));
}
assert!(target.join("portable.json").is_file());
let _ = std::fs::remove_dir_all(&parent);
}
}

View File

@@ -48,7 +48,7 @@ pub async fn check_frontend_update() -> Result<Value, String> {
.to_string();
// 优先读取已热更新的版本,避免 macOS/Linux 用户安装旧包后永远提示有更新
let current = {
let frontend_current = {
let version_file = update_dir().join(".version");
std::fs::read_to_string(&version_file)
.ok()
@@ -63,13 +63,14 @@ pub async fn check_frontend_update() -> Result<Value, String> {
.and_then(|v| v.as_str())
.unwrap_or("0.0.0");
let compatible = version_ge(&current, min_app);
let remote_newer = !latest.is_empty() && compatible && version_gt(&latest, &current);
let app_version = env!("CARGO_PKG_VERSION");
let compatible = version_ge(app_version, min_app);
let remote_newer = !latest.is_empty() && compatible && version_gt(&latest, &frontend_current);
let update_ready = remote_newer && update_dir().join("index.html").exists();
let has_update = remote_newer && !update_ready;
Ok(serde_json::json!({
"currentVersion": current,
"currentVersion": frontend_current,
"latestVersion": latest,
"hasUpdate": has_update,
"compatible": compatible,

View File

@@ -266,6 +266,7 @@ pub fn run() {
hermes::configure_hermes,
hermes::hermes_gateway_action,
hermes::hermes_health_check,
hermes::hermes_probe_gateway,
hermes::hermes_capabilities,
hermes::hermes_api_proxy,
hermes::hermes_agent_run,
@@ -381,6 +382,7 @@ pub fn run() {
hermes_providers::hermes_list_providers,
hermes::hermes_env_read_unmanaged,
hermes::hermes_env_set,
hermes::hermes_sync_provider,
hermes::hermes_env_delete,
hermes::hermes_env_reveal,
hermes::hermes_config_raw_read,

View File

@@ -325,6 +325,57 @@ pub fn path_compare_key(path: &std::path::Path) -> String {
}
}
/// 将路径解析为可用于安全比较的绝对路径。
/// 已存在路径直接 canonicalize不存在路径 canonicalize 最近的存在祖先后再拼回尾部。
pub fn canonicalize_path_for_safety(
path: &std::path::Path,
label: &str,
) -> Result<std::path::PathBuf, String> {
use std::path::Component;
if path.as_os_str().is_empty() {
return Err(format!("{label}不能为空"));
}
if path
.components()
.any(|part| matches!(part, Component::ParentDir))
{
return Err(format!("{label}不能包含 .. 路径段"));
}
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| format!("读取当前目录失败: {e}"))?
.join(path)
};
if absolute.exists() {
return std::fs::canonicalize(&absolute)
.map_err(|e| format!("解析{label} {} 失败: {e}", absolute.display()));
}
let mut ancestor = absolute.clone();
let mut missing = Vec::new();
while !ancestor.exists() {
let name = ancestor
.file_name()
.ok_or_else(|| format!("找不到{label}的存在祖先: {}", absolute.display()))?
.to_os_string();
missing.push(name);
if !ancestor.pop() {
return Err(format!("找不到{label}的存在祖先: {}", absolute.display()));
}
}
let mut resolved = std::fs::canonicalize(&ancestor)
.map_err(|e| format!("解析{label}祖先 {} 失败: {e}", ancestor.display()))?;
for name in missing.into_iter().rev() {
resolved.push(name);
}
Ok(resolved)
}
/// path 是否等于 base 或位于 base 之下带路径分隔符边界media 不会误匹配 media-evil
pub fn path_is_inside_or_same(path: &std::path::Path, base: &std::path::Path) -> bool {
let path_key = path_compare_key(path);