fix(host): 允许布局记录通过实际命令策略

This commit is contained in:
2026-09-09 07:41:39 +08:00
parent 9ce36b1d92
commit 8d7d23a4ae
+107 -31
View File
@@ -1,6 +1,9 @@
//! Main-window preference records; no generic credential or application-state accessor. //! Main-window preference records; no generic credential or application-state accessor.
use super::{with_workspace, Host}; use super::{with_workspace, Host};
use notesagent_host::{records, workspace::HostError}; use notesagent_host::{
records,
workspace::{HostError, Result as HostResult, Workspace},
};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use tauri::State; use tauri::State;
@@ -19,43 +22,116 @@ pub struct Write {
expected: String, expected: String,
operation_id: String, operation_id: String,
} }
fn preference(kind: &str) -> Result<(), String> { fn preference(kind: &str) -> HostResult<()> {
if matches!(kind, "theme_settings" | "preferences") { if matches!(kind, "theme_settings" | "preferences" | "layout") {
Ok(()) Ok(())
} else { } else {
Err("RECORD_SCOPE_DENIED".into()) Err(HostError::new("RECORD_SCOPE_DENIED"))
} }
} }
fn get_record(ws: &mut Workspace, request: Get) -> HostResult<Option<Value>> {
preference(&request.kind)?;
if ws.vault_id != request.vault_id {
return Err(HostError::new("VAULT_CHANGED"));
}
ws.record_get_kind(&request.kind, &request.id)
}
fn write_record(ws: &mut Workspace, request: Write) -> HostResult<Value> {
let kind = request.record["kind"]
.as_str()
.ok_or_else(|| HostError::new("RECORD_SCHEMA_INVALID"))?;
preference(kind)?;
if ws.vault_id != request.vault_id {
return Err(HostError::new("VAULT_CHANGED"));
}
let path = records::path_for(kind, request.record["id"].as_str().unwrap_or(""))?;
let bytes =
serde_json::to_vec(&request.record).map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))?;
let entry = ws.write_operation(
&path,
&request.expected,
&bytes,
"local",
&request.operation_id,
)?;
Ok(json!({"record":request.record,"hash":entry.hash,"file_id":entry.file_id}))
}
#[tauri::command] #[tauri::command]
pub fn record_get(host: State<'_, Host>, request: Get) -> Result<Option<Value>, String> { pub fn record_get(host: State<'_, Host>, request: Get) -> Result<Option<Value>, String> {
preference(&request.kind)?; with_workspace(&host, |ws| get_record(ws, request))
with_workspace(&host, |ws| {
if ws.vault_id != request.vault_id {
return Err(HostError::new("VAULT_CHANGED"));
}
ws.record_get_kind(&request.kind, &request.id)
})
} }
#[tauri::command] #[tauri::command]
pub fn record_write(host: State<'_, Host>, request: Write) -> Result<Value, String> { pub fn record_write(host: State<'_, Host>, request: Write) -> Result<Value, String> {
let kind = request.record["kind"] with_workspace(&host, |ws| write_record(ws, request))
.as_str() }
.ok_or("RECORD_SCHEMA_INVALID")?;
preference(kind)?; #[cfg(test)]
let path = mod tests {
records::path_for(kind, request.record["id"].as_str().unwrap_or("")).map_err(|e| e.code)?; use super::*;
let bytes = serde_json::to_vec(&request.record).map_err(|_| "RECORD_SCHEMA_INVALID")?; #[test]
with_workspace(&host, |ws| { fn layout_traverses_the_actual_command_policy_and_workspace_journal() {
if ws.vault_id != request.vault_id { let root = tempfile::tempdir().unwrap();
return Err(HostError::new("VAULT_CHANGED")); let mut ws = Workspace::open(root.path()).unwrap();
} let record = json!({"schema":1,"kind":"layout","id":"sidebars","data":{"primaryExpanded":true,"workspaceWidth":400,"chatWidth":320}});
let entry = ws.write_operation( let operation = uuid::Uuid::new_v4().to_string();
&path, let make = |vault: &str| Write {
&request.expected, vault_id: vault.into(),
&bytes, record: record.clone(),
"local", expected: String::new(),
&request.operation_id, operation_id: operation.clone(),
)?; };
Ok(json!({"record":request.record,"hash":entry.hash,"file_id":entry.file_id})) let vault = ws.vault_id.clone();
}) let first = write_record(&mut ws, make(&vault)).unwrap();
assert_eq!(write_record(&mut ws, make(&vault)).unwrap(), first);
assert_eq!(ws.pending_count().unwrap(), 1);
drop(ws);
let mut ws = Workspace::open(root.path()).unwrap();
let loaded = get_record(
&mut ws,
Get {
vault_id: vault,
kind: "layout".into(),
id: "sidebars".into(),
},
)
.unwrap()
.unwrap();
assert_eq!(loaded, first);
assert_eq!(
write_record(&mut ws, make("different-vault"))
.unwrap_err()
.code,
"VAULT_CHANGED"
);
assert_eq!(ws.pending_count().unwrap(), 1);
}
#[test]
fn preference_commands_do_not_become_a_generic_record_accessor() {
let root = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(root.path()).unwrap();
for kind in ["persona", "task", "credentials", "user_skill"] {
let request = Get {
vault_id: ws.vault_id.clone(),
kind: kind.into(),
id: "default".into(),
};
assert_eq!(
get_record(&mut ws, request).unwrap_err().code,
"RECORD_SCOPE_DENIED"
);
}
let request = Write {
vault_id: ws.vault_id.clone(),
record: json!({"schema":1,"kind":"layout","id":"sidebars","data":{"primaryExpanded":true,"workspaceWidth":400,"chatWidth":320,"token":"forbidden"}}),
expected: String::new(),
operation_id: uuid::Uuid::new_v4().to_string(),
};
assert_eq!(
write_record(&mut ws, request).unwrap_err().code,
"RECORD_SCHEMA_INVALID"
);
assert_eq!(ws.pending_count().unwrap(), 0);
}
} }