mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-08-07 22:53:58 +08:00
fix(release): harden pre-release stability gates
This commit is contained in:
@@ -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(¤t_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(¤t_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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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, ×tamp);
|
||||
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, ×tamp)
|
||||
.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, ×tamp);
|
||||
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, ×tamp) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¤t, min_app);
|
||||
let remote_newer = !latest.is_empty() && compatible && version_gt(&latest, ¤t);
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user