fix(config): prevent auto-pair from clobbering gateway auth on concurrent writes

patch_gateway_origins() read a full config snapshot and wrote it back via
write_openclaw_config. Because gateway fields are shallow-merged, a stale
snapshot could revert concurrent edits to gateway.auth, gateway.port, etc.
while auto_pair_device runs on startup or WS reconnect.

Write only a partial delta for controlUi.allowedOrigins and add a regression
test documenting the merge behavior.

Co-authored-by: 晴天 <1186258278@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-06-07 11:06:52 +00:00
parent c82dc13fad
commit 783340296b
2 changed files with 102 additions and 43 deletions

View File

@@ -814,6 +814,46 @@ pub fn save_openclaw_json(config: &Value) -> Result<(), String> {
write_openclaw_config(config.clone())
}
/// Append required Gateway control-ui origins without clobbering other gateway fields.
///
/// Callers must pass a **partial** delta (not a full config snapshot). `write_openclaw_config`
/// shallow-merges top-level objects; a stale full snapshot would revert concurrent edits to
/// `gateway.auth`, `gateway.port`, etc.
pub fn append_gateway_allowed_origins(required: &[&str]) -> Result<(), String> {
let config = load_openclaw_json().unwrap_or_else(|_| json!({}));
let existing: Vec<String> = config
.pointer("/gateway/controlUi/allowedOrigins")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|value| value.as_str().map(|value| value.to_string()))
.collect()
})
.unwrap_or_default();
let mut merged = existing.clone();
let mut changed = false;
for origin in required {
let origin = (*origin).to_string();
if !merged.iter().any(|existing| existing == &origin) {
merged.push(origin);
changed = true;
}
}
if !changed {
return Ok(());
}
let delta = json!({
"gateway": {
"controlUi": {
"allowedOrigins": merged
}
}
});
write_openclaw_config(delta)
}
fn model_env_values_for_config(config: &Value) -> HashMap<String, String> {
let mut values = HashMap::new();
if let Some(env) = config.get("env").and_then(|v| v.as_object()) {
@@ -7215,6 +7255,61 @@ mod write_openclaw_config_merge_tests {
std::env::temp_dir().join(format!("clawpanel-{name}-{}-{suffix}", std::process::id()))
}
/// Regression: auto-pair must patch allowedOrigins with a partial delta, not a stale
/// full config snapshot — otherwise shallow gateway merge reverts concurrent auth/port edits.
#[test]
fn partial_gateway_patch_preserves_auth_and_port() {
let existing = json!({
"gateway": {
"port": 9999,
"auth": { "mode": "token", "token": "user-new-token" },
"controlUi": { "allowedOrigins": ["http://localhost"] }
}
});
let stale_full_snapshot = json!({
"gateway": {
"port": 18789,
"auth": { "mode": "token", "token": "old-token" },
"controlUi": {
"allowedOrigins": [
"http://localhost",
"tauri://localhost",
"http://localhost:1420"
]
}
}
});
let clobbered = merge_configs_preserving_fields(&existing, &stale_full_snapshot);
assert_eq!(
clobbered["gateway"]["auth"]["token"],
"old-token",
"full snapshot merge must reproduce the clobber bug for this test"
);
let delta = json!({
"gateway": {
"controlUi": {
"allowedOrigins": [
"http://localhost",
"tauri://localhost",
"http://localhost:1420"
]
}
}
});
let merged = merge_configs_preserving_fields(&existing, &delta);
assert_eq!(merged["gateway"]["auth"]["token"], "user-new-token");
assert_eq!(merged["gateway"]["port"], 9999);
assert_eq!(
merged["gateway"]["controlUi"]["allowedOrigins"],
json!([
"http://localhost",
"tauri://localhost",
"http://localhost:1420"
])
);
}
/// Regression guard: Issue #127 merge keeps full provider map when the UI payload
/// only touches one provider — `sync_providers_to_agent_models` must use the same
/// merged view (see `write_openclaw_config`), not the raw `config` argument.

View File

@@ -297,50 +297,14 @@ pub fn auto_pair_device() -> Result<String, String> {
/// 将 Tauri 应用的 origin 写入 gateway.controlUi.allowedOrigins
/// 避免 Gateway 因 origin not allowed 拒绝 WebSocket 握手
fn patch_gateway_origins() {
let Ok(mut config) = super::config::load_openclaw_json() else {
return;
};
// Tauri 应用 + 本地开发服务器必须存在的 origin
let required: Vec<String> = vec![
"tauri://localhost".into(),
"https://tauri.localhost".into(),
"http://tauri.localhost".into(),
"http://localhost:1420".into(),
"http://127.0.0.1:1420".into(),
const REQUIRED: &[&str] = &[
"tauri://localhost",
"https://tauri.localhost",
"http://tauri.localhost",
"http://localhost:1420",
"http://127.0.0.1:1420",
];
if let Some(obj) = config.as_object_mut() {
let gateway = obj
.entry("gateway")
.or_insert_with(|| serde_json::json!({}));
if let Some(gw) = gateway.as_object_mut() {
let control_ui = gw
.entry("controlUi")
.or_insert_with(|| serde_json::json!({}));
if let Some(cui) = control_ui.as_object_mut() {
// 合并:保留用户已有的 origin追加缺失的 Tauri origin
let existing: Vec<String> = cui
.get("allowedOrigins")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|s| s.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let mut merged = existing;
for r in &required {
if !merged.iter().any(|e| e == r) {
merged.push(r.clone());
}
}
cui.insert("allowedOrigins".to_string(), serde_json::json!(merged));
}
}
}
let _ = super::config::save_openclaw_json(&config);
let _ = super::config::append_gateway_allowed_origins(REQUIRED);
}
#[tauri::command]