fix(opencode): support unified OMO config (#6011)

* fix(opencode): support unified OMO config

* fix(opencode): couple rollback snapshots to writes

* fix(ci): simplify rollback version type

* fix OMO unified config review issues

* validate OMO round-trip semantics
This commit is contained in:
Allen Xu authored and GitHub committed 2026-08-05 09:04:15 +08:00
1 parent 290b65c03b
commit 0345fad604
3 files changed
+1588 -189

No files matched your search

+140 -20
View File
@@ -290,8 +290,11 @@ fn sort_json_keys(value: &Value) -> Value {
}
}
/// 写入 JSON 配置文件(键按字母排序,确保确定性输出)
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppError> {
/// 写入 JSON 配置文件并返回实际写入的字节。
pub fn write_json_file_with_contents<T: Serialize>(
path: &Path,
data: &T,
) -> Result<Vec<u8>, AppError> {
// 确保目录存在
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
@@ -302,7 +305,14 @@ pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppErr
let json = serde_json::to_string_pretty(&sorted_value)
.map_err(|e| AppError::JsonSerialize { source: e })?;
atomic_write(path, json.as_bytes())
let contents = json.into_bytes();
atomic_write(path, &contents)?;
Ok(contents)
}
/// 写入 JSON 配置文件(键按字母排序,确保确定性输出)
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppError> {
write_json_file_with_contents(path, data).map(|_| ())
}
/// 原子写入文本文件(用于 TOML/纯文本)
@@ -322,7 +332,6 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
let parent = path
.parent()
.ok_or_else(|| AppError::Config("无效的路径".to_string()))?;
let mut tmp = parent.to_path_buf();
let file_name = path
.file_name()
.ok_or_else(|| AppError::Config("无效的文件名".to_string()))?
@@ -332,13 +341,38 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
tmp.push(format!("{file_name}.tmp.{ts}"));
static TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let (tmp, mut file) = (|| -> Result<(PathBuf, fs::File), AppError> {
let mut last_collision = None;
for _ in 0..16 {
let counter = TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let candidate = parent.join(format!(
"{file_name}.tmp.{}.{ts}.{counter}",
std::process::id()
));
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&candidate)
{
Ok(file) => return Ok((candidate, file)),
Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
last_collision = Some((candidate, source));
}
Err(source) => return Err(AppError::io(&candidate, source)),
}
}
{
let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;
f.write_all(data).map_err(|e| AppError::io(&tmp, e))?;
f.flush().map_err(|e| AppError::io(&tmp, e))?;
let (candidate, source) = last_collision.expect("temporary filename loop must run");
Err(AppError::io(&candidate, source))
})()?;
if let Err(source) = file.write_all(data).and_then(|_| file.flush()) {
drop(file);
let _ = fs::remove_file(&tmp);
return Err(AppError::io(&tmp, source));
}
drop(file);
#[cfg(unix)]
{
@@ -351,22 +385,85 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
#[cfg(windows)]
{
// Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性)
if path.exists() {
let _ = fs::remove_file(path);
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::ReplaceFileW;
let replaced: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let replacement: Vec<u16> = tmp
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut completed = false;
let mut last_error = None;
for _ in 0..3 {
// SAFETY: both path buffers are NUL-terminated UTF-16 and remain alive for the
// duration of the call. Backup, exclusion, and reserved pointers are intentionally null.
let replaced_ok = unsafe {
ReplaceFileW(
replaced.as_ptr(),
replacement.as_ptr(),
std::ptr::null(),
0,
std::ptr::null(),
std::ptr::null(),
)
};
if replaced_ok != 0 {
completed = true;
break;
}
let replace_error = std::io::Error::last_os_error();
if replace_error.kind() != std::io::ErrorKind::NotFound {
last_error = Some(replace_error);
break;
}
match fs::rename(&tmp, path) {
Ok(()) => {
completed = true;
break;
}
Err(source)
if matches!(
source.kind(),
std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::PermissionDenied
) =>
{
last_error = Some(source);
}
Err(source) => {
last_error = Some(source);
break;
}
}
}
if !completed {
let source = last_error.unwrap_or_else(std::io::Error::last_os_error);
let _ = fs::remove_file(&tmp);
return Err(AppError::IoContext {
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
source,
});
}
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
source: e,
})?;
}
#[cfg(not(windows))]
{
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
source: e,
})?;
if let Err(source) = fs::rename(&tmp, path) {
let _ = fs::remove_file(&tmp);
return Err(AppError::IoContext {
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
source,
});
}
}
Ok(())
}
@@ -375,6 +472,29 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn atomic_write_preserves_destination_when_windows_replace_fails() {
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
std::fs::write(&path, b"old contents").unwrap();
let held_file = std::fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ)
.open(&path)
.unwrap();
let result = atomic_write(&path, b"new contents");
assert!(result.is_err());
drop(held_file);
assert_eq!(std::fs::read(&path).unwrap(), b"old contents");
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
}
#[test]
fn derive_mcp_path_from_override_uses_config_dir_for_custom_path() {
let override_dir = PathBuf::from("/tmp/profile/.claude");
+155 -56
View File
@@ -1,13 +1,26 @@
use crate::config::write_json_file;
use crate::config::write_json_file_with_contents;
use crate::error::AppError;
use crate::provider::OpenCodeProviderConfig;
use crate::settings::get_opencode_override_dir;
use indexmap::IndexMap;
use serde_json::{json, Map, Value};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
const STANDARD_OMO_PLUGIN_PREFIXES: [&str; 2] = ["oh-my-openagent", "oh-my-opencode"];
const SLIM_OMO_PLUGIN_PREFIXES: [&str; 1] = ["oh-my-opencode-slim"];
fn opencode_config_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn read_config_contents(path: &Path) -> Result<Option<Vec<u8>>, AppError> {
match std::fs::read(path) {
Ok(contents) => Ok(Some(contents)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(AppError::io(path, err)),
}
}
fn matches_plugin_prefix(plugin_name: &str, prefix: &str) -> bool {
plugin_name == prefix
@@ -85,16 +98,16 @@ pub fn get_opencode_env_path() -> PathBuf {
get_opencode_dir().join(".env")
}
pub fn read_opencode_config() -> Result<Value, AppError> {
let path = get_opencode_config_path();
if !path.exists() {
return Ok(json!({
"$schema": "https://opencode.ai/config.json"
}));
}
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
fn read_opencode_config_from_path(path: &Path) -> Result<Value, AppError> {
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(json!({
"$schema": "https://opencode.ai/config.json"
}));
}
Err(err) => return Err(AppError::io(path, err)),
};
let value: Value = json5::from_str(&content).map_err(|e| {
AppError::Config(format!(
"Failed to parse OpenCode config: {}: {e}",
@@ -118,12 +131,18 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
Ok(value)
}
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
let path = get_opencode_config_path();
write_json_file(&path, config)?;
pub fn read_opencode_config() -> Result<Value, AppError> {
read_opencode_config_from_path(&get_opencode_config_path())
}
fn write_opencode_config_to_path_with_contents(
path: &Path,
config: &Value,
) -> Result<Vec<u8>, AppError> {
let contents = write_json_file_with_contents(path, config)?;
log::debug!("OpenCode config written to {path:?}");
Ok(())
Ok(contents)
}
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
@@ -136,7 +155,9 @@ pub fn get_providers() -> Result<Map<String, Value>, AppError> {
}
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
let mut full_config = read_opencode_config()?;
let _guard = opencode_config_lock().lock()?;
let path = get_opencode_config_path();
let mut full_config = read_opencode_config_from_path(&path)?;
// 判空要连「存在但不是对象」一起算:否则下面 as_object_mut 拿不到,
// 写入会静默失效——界面显示添加成功而文件里没有。provider 段是 cc-switch
@@ -155,11 +176,13 @@ pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
providers.insert(id.to_string(), config);
}
write_opencode_config(&full_config)
write_opencode_config_to_path_with_contents(&path, &full_config).map(|_| ())
}
pub fn remove_provider(id: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
let _guard = opencode_config_lock().lock()?;
let path = get_opencode_config_path();
let mut config = read_opencode_config_from_path(&path)?;
if let Some(providers) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
providers.remove(id);
@@ -167,7 +190,7 @@ pub fn remove_provider(id: &str) -> Result<(), AppError> {
log::warn!("opencode.json 的 provider 不是对象,无法删除供应商 '{id}'");
}
write_opencode_config(&config)
write_opencode_config_to_path_with_contents(&path, &config).map(|_| ())
}
pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>, AppError> {
@@ -203,7 +226,9 @@ pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
}
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
let mut full_config = read_opencode_config()?;
let _guard = opencode_config_lock().lock()?;
let path = get_opencode_config_path();
let mut full_config = read_opencode_config_from_path(&path)?;
if !full_config.get("mcp").is_some_and(Value::is_object) {
if full_config.get("mcp").is_some() {
@@ -216,11 +241,13 @@ pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
mcp.insert(id.to_string(), config);
}
write_opencode_config(&full_config)
write_opencode_config_to_path_with_contents(&path, &full_config).map(|_| ())
}
pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
let _guard = opencode_config_lock().lock()?;
let path = get_opencode_config_path();
let mut config = read_opencode_config_from_path(&path)?;
if let Some(mcp) = config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
mcp.remove(id);
@@ -228,70 +255,98 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
log::warn!("opencode.json 的 mcp 不是对象,无法删除服务器 '{id}'");
}
write_opencode_config(&config)
write_opencode_config_to_path_with_contents(&path, &config).map(|_| ())
}
pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
pub fn add_plugin(path: &Path, plugin_name: &str) -> Result<(), AppError> {
let _guard = opencode_config_lock().lock()?;
let mut config = read_opencode_config_from_path(path)?;
let normalized_plugin_name = canonicalize_plugin_name(plugin_name);
let target_is_omo =
matches_any_plugin_prefix(&normalized_plugin_name, &STANDARD_OMO_PLUGIN_PREFIXES)
|| matches_any_plugin_prefix(&normalized_plugin_name, &SLIM_OMO_PLUGIN_PREFIXES);
let mut changed = false;
let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut());
match plugins {
Some(arr) => {
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
if matches_any_plugin_prefix(&normalized_plugin_name, &STANDARD_OMO_PLUGIN_PREFIXES) {
arr.retain(|v| {
v.as_str()
.map(|s| {
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
})
.unwrap_or(true)
});
} else if matches_any_plugin_prefix(&normalized_plugin_name, &SLIM_OMO_PLUGIN_PREFIXES)
{
arr.retain(|v| {
v.as_str()
.map(|s| {
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
})
.unwrap_or(true)
});
}
let mut found_target = false;
arr.retain(|value| {
let Some(existing_name) = value.as_str() else {
return true;
};
if existing_name == normalized_plugin_name {
if found_target {
changed = true;
return false;
}
found_target = true;
return true;
}
let already_exists = arr
.iter()
.any(|v| v.as_str() == Some(normalized_plugin_name.as_str()));
if !already_exists {
// Standard OMO and OMO Slim are mutually exclusive.
if target_is_omo
&& (matches_any_plugin_prefix(existing_name, &STANDARD_OMO_PLUGIN_PREFIXES)
|| matches_any_plugin_prefix(existing_name, &SLIM_OMO_PLUGIN_PREFIXES))
{
changed = true;
return false;
}
true
});
if !found_target {
arr.push(Value::String(normalized_plugin_name));
changed = true;
}
}
None => {
config["plugin"] = json!([normalized_plugin_name]);
changed = true;
}
}
write_opencode_config(&config)
if !changed {
return Ok(());
}
write_opencode_config_to_path_with_contents(path, &config).map(|_| ())
}
pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
pub fn remove_plugins_by_prefixes(path: &Path, prefixes: &[&str]) -> Result<bool, AppError> {
let _guard = opencode_config_lock().lock()?;
let previous_contents = read_config_contents(path)?;
let mut config = read_opencode_config_from_path(path)?;
let mut changed = false;
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
let previous_len = arr.len();
arr.retain(|v| {
v.as_str()
.map(|s| !matches_any_plugin_prefix(s, prefixes))
.unwrap_or(true)
});
changed = arr.len() != previous_len;
if arr.is_empty() {
if changed && arr.is_empty() {
config.as_object_mut().map(|obj| obj.remove("plugin"));
}
}
write_opencode_config(&config)
if !changed {
return Ok(false);
}
let current_contents = read_config_contents(path)?;
if current_contents != previous_contents {
return Err(AppError::Config(
"OpenCode config changed on disk. Please reload and try again.".to_string(),
));
}
write_opencode_config_to_path_with_contents(path, &config)?;
Ok(true)
}
#[cfg(test)]
@@ -366,4 +421,48 @@ mod tests {
"unrelated user config must be preserved"
);
}
#[test]
fn remove_missing_plugin_does_not_create_config_file() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("opencode.json");
let result = remove_plugins_by_prefixes(&path, &["oh-my-openagent"]).unwrap();
assert!(!result);
assert!(!path.exists());
}
#[test]
fn remove_missing_plugin_preserves_existing_source() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("opencode.json");
let original = r#"{
// Keep formatting when the target plugin is absent.
"plugin": ["unrelated-plugin"],
"theme": "dark",
}"#;
std::fs::write(&path, original).unwrap();
let result = remove_plugins_by_prefixes(&path, &["oh-my-openagent"]).unwrap();
assert!(!result);
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
}
#[test]
fn add_existing_plugin_preserves_existing_source() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("opencode.json");
let original = r#"{
// Keep comments and formatting when the plugin is already configured.
plugin: ['oh-my-openagent@latest'],
theme: 'dark',
}"#;
std::fs::write(&path, original).unwrap();
add_plugin(&path, "oh-my-openagent@latest").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
}
}
File diff suppressed because it is too large. Load diff