From 4f3b67afcb66443014a0c59ab0c70768f79dbaa6 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Fri, 11 Sep 2026 21:18:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E9=BD=90=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1=E4=B8=8E=E6=92=A4=E9=94=80=E7=94=9F=E5=91=BD?= =?UTF-8?q?=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src-tauri/src/extension_commands.rs | 102 +++++++++++++ frontend/src-tauri/src/extension_instance.rs | 30 +++- frontend/src-tauri/src/extension_store.rs | 23 ++- .../src-tauri/src/extension_transaction.rs | 136 +++++++++++++++++- frontend/src-tauri/src/main.rs | 13 ++ 5 files changed, 295 insertions(+), 9 deletions(-) diff --git a/frontend/src-tauri/src/extension_commands.rs b/frontend/src-tauri/src/extension_commands.rs index 34b4ac5..30d102a 100644 --- a/frontend/src-tauri/src/extension_commands.rs +++ b/frontend/src-tauri/src/extension_commands.rs @@ -146,6 +146,108 @@ pub async fn extension_install_preview( .map_err(|_| "EXTENSION_PREVIEW_FAILED".to_string())? } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InstallConfirmation { + request_id: String, + operation_id: String, + fingerprint: String, + root_key: String, + vault_id: String, + configurations: std::collections::BTreeMap, +} + +#[tauri::command] +pub async fn extension_install_confirm( + window: WebviewWindow, + host: State<'_, Host>, + request: InstallConfirmation, +) -> Result { + main_window(&window)?; + let mut lease = host.extension_requests.claim(&request.request_id)?; + let checkpoint = lease.checkpoint(); + let workspace = host.workspace.clone(); + let extensions = host.extensions.clone(); + tauri::async_runtime::spawn_blocking(move || { + let workspace = workspace.lock().map_err(|_| "HOST_BUSY")?; + if workspace.as_ref().ok_or("VAULT_NOT_OPEN")?.vault_id != request.vault_id { + return Err("VAULT_CHANGED".into()); + } + let install = InstallRequest { + root_key: request.root_key, + vault_id: request.vault_id, + app_version: env!("CARGO_PKG_VERSION").into(), + platform: std::env::consts::OS.into(), + architecture: std::env::consts::ARCH.into(), + configurations: request.configurations, + }; + let mut store = extensions.lock().map_err(|_| "HOST_BUSY")?; + let receipt = tauri::async_runtime::block_on(lease.run(async { + checkpoint()?; + store + .as_mut() + .ok_or("EXTENSIONS_NOT_READY")? + .install_confirmed(&request.operation_id, &install, &request.fingerprint) + .await + .map_err(|error| error.code) + }))?; + serde_json::to_value(receipt).map_err(|_| "EXTENSION_INSTALL_FAILED".into()) + }) + .await + .map_err(|_| "EXTENSION_INSTALL_FAILED".to_string())? +} + +#[tauri::command] +pub async fn extension_install_rollback( + window: WebviewWindow, + host: State<'_, Host>, + operation_id: String, + installed_operation_id: String, + vault_id: String, +) -> Result { + main_window(&window)?; + let workspace = host.workspace.clone(); + let extensions = host.extensions.clone(); + tauri::async_runtime::spawn_blocking(move || { + let workspace = workspace.lock().map_err(|_| "HOST_BUSY")?; + if workspace.as_ref().ok_or("VAULT_NOT_OPEN")?.vault_id != vault_id { + return Err("VAULT_CHANGED".into()); + } + let mut store = extensions.lock().map_err(|_| "HOST_BUSY")?; + let store = store.as_mut().ok_or("EXTENSIONS_NOT_READY")?; + let changes = store + .rollback_changes(&installed_operation_id) + .map_err(|error| error.code)?; + let receipt = + tauri::async_runtime::block_on(store.switch_online(&operation_id, &vault_id, &changes)) + .map_err(|error| error.code)?; + serde_json::to_value(receipt).map_err(|_| "EXTENSION_ROLLBACK_FAILED".into()) + }) + .await + .map_err(|_| "EXTENSION_ROLLBACK_FAILED".to_string())? +} + +#[tauri::command] +pub fn extension_uninstall( + window: WebviewWindow, + host: State<'_, Host>, + operation_id: String, + slot: String, + expected_revision: String, +) -> Result { + main_window(&window)?; + host.extension_authority.revoke(); + #[cfg(windows)] + host.extension_instances + .lock() + .map_err(|_| "HOST_BUSY")? + .stop_all_and_join(); + let receipt = store(&host, |s| { + s.uninstall_active(&operation_id, &slot, &expected_revision) + })?; + serde_json::to_value(receipt).map_err(|_| "EXTENSION_UNINSTALL_FAILED".into()) +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub struct StageRequest { diff --git a/frontend/src-tauri/src/extension_instance.rs b/frontend/src-tauri/src/extension_instance.rs index 1cabb7e..0f0c876 100644 --- a/frontend/src-tauri/src/extension_instance.rs +++ b/frontend/src-tauri/src/extension_instance.rs @@ -372,14 +372,24 @@ impl Registry { entry.endpoint.stop(); } } -} -impl Drop for Registry { - fn drop(&mut self) { + + /// 请求停止并等待所有实例释放工具、进程、容器和包 ACL。 + pub fn stop_all_and_join(&mut self) { self.stop_all(); for (_, entry) in std::mem::take(&mut self.entries) { let _ = entry.worker.join(); } } + + pub fn active_count(&mut self) -> usize { + self.reap(); + self.entries.len() + } +} +impl Drop for Registry { + fn drop(&mut self) { + self.stop_all_and_join(); + } } fn run(spec: LaunchSpec, control: &Control, receiver: Receiver) -> Result<()> { if control.stop.load(Ordering::Acquire) { @@ -470,6 +480,9 @@ fn run_with_access( .status .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire); while !control.stop.load(Ordering::Acquire) { + // 撤销、锁定和工作区切换必须在空闲实例上也能生效,不能等待下一次工具调用。 + spec.authority + .verify(&spec.permit, &spec.claims, now_ms()?)?; session.drain_pending()?; if session.take_tools_changed() { control @@ -525,6 +538,7 @@ mod tests { fn native_worker_routes_reviews_cancels_calls_and_reaps_generations() { native_worker_lifecycle(false); } + #[test] #[ignore = "real background MCP CPU/memory/process exhaustion and restart; run explicitly"] fn native_resource_failures_are_reaped_and_replacements_can_start() { @@ -653,17 +667,23 @@ mod tests { .code, "EXTENSION_CALL_REVIEW_UNKNOWN" ); - endpoint.stop(); + // 空闲实例也必须在许可撤销后自行退出并清空工具注册。 + authority.revoke(); wait_for(|| { registry.reap(); registry.entries.is_empty() }); assert_eq!( endpoint.snapshot().status, - Status::Stopped, + Status::Failed, "{:?}", endpoint.snapshot().error ); + assert_eq!(endpoint.snapshot().tool_count, 0); + assert_eq!( + endpoint.snapshot().error.as_deref(), + Some("EXTENSION_PERMIT_REVOKED") + ); assert!(endpoint.review("echo".into(), json!({})).is_err()); assert_eq!( endpoint diff --git a/frontend/src-tauri/src/extension_store.rs b/frontend/src-tauri/src/extension_store.rs index 46d3ab8..17cac25 100644 --- a/frontend/src-tauri/src/extension_store.rs +++ b/frontend/src-tauri/src/extension_store.rs @@ -288,10 +288,10 @@ impl ExtensionStore { let mut db = Connection::open(database)?; db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?; let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?; - if version > 6 { + if version > 7 { return Err(HostError::new("EXTENSION_SCHEMA_INCOMPATIBLE")); } - if (1..6).contains(&version) { + if (1..7).contains(&version) { let backup = root.join(format!( "extensions.schema{version}.{}.sqlite3", Uuid::new_v4() @@ -308,8 +308,9 @@ impl ExtensionStore { CREATE TABLE IF NOT EXISTS extension_trust(source TEXT NOT NULL,namespace TEXT NOT NULL,key_id TEXT NOT NULL,setting TEXT NOT NULL,revision TEXT NOT NULL,PRIMARY KEY(source,namespace,key_id)); CREATE TABLE IF NOT EXISTS extension_blocks(identity TEXT PRIMARY KEY,reason TEXT NOT NULL); CREATE TABLE IF NOT EXISTS extension_confirmations(operation_id TEXT PRIMARY KEY,request_hash TEXT NOT NULL,review_hash TEXT NOT NULL,changes TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS extension_uninstalls(id TEXT PRIMARY KEY,slot TEXT NOT NULL,expected_revision TEXT NOT NULL,state TEXT NOT NULL); CREATE TABLE IF NOT EXISTS legacy_installations(kind TEXT NOT NULL,package_id TEXT NOT NULL,source_path TEXT NOT NULL,expected_digest TEXT NOT NULL,observed_digest TEXT,ownership TEXT NOT NULL,state TEXT NOT NULL,enabled INTEGER NOT NULL CHECK(enabled=0),permissions TEXT NOT NULL CHECK(permissions='[]'),source_db_digest TEXT NOT NULL,PRIMARY KEY(kind,package_id)); - PRAGMA user_version=6; COMMIT;")?; + PRAGMA user_version=7; COMMIT;")?; crate::extension_transaction::recover(&mut db)?; Ok(Self { root, @@ -849,6 +850,22 @@ impl ExtensionStore { ) -> Result> { crate::extension_transaction::active(&self.db, slot) } + + pub fn rollback_changes( + &self, + operation: &str, + ) -> Result> { + crate::extension_transaction::rollback_changes(&self.db, operation) + } + + pub fn uninstall_active( + &mut self, + operation: &str, + slot: &str, + expected_revision: &str, + ) -> Result { + crate::extension_transaction::uninstall(&mut self.db, operation, slot, expected_revision) + } /// 准备经过验证的暂存包。调用者提供当前的签名者/撤销策略;持久准备不会在重放时绕过该策略。 pub fn prepare( &mut self, diff --git a/frontend/src-tauri/src/extension_transaction.rs b/frontend/src-tauri/src/extension_transaction.rs index 937dcff..82c804c 100644 --- a/frontend/src-tauri/src/extension_transaction.rs +++ b/frontend/src-tauri/src/extension_transaction.rs @@ -31,7 +31,8 @@ pub struct Receipt { } pub fn schema(db: &Connection) -> Result<()> { db.execute_batch("CREATE TABLE IF NOT EXISTS extension_active(slot TEXT PRIMARY KEY,target TEXT NOT NULL,revision TEXT NOT NULL,pending_operation TEXT); - CREATE TABLE IF NOT EXISTS extension_transactions(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,before_state TEXT NOT NULL,after_state TEXT NOT NULL,state TEXT NOT NULL);")?; + CREATE TABLE IF NOT EXISTS extension_transactions(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,before_state TEXT NOT NULL,after_state TEXT NOT NULL,state TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS extension_uninstalls(id TEXT PRIMARY KEY,slot TEXT NOT NULL,expected_revision TEXT NOT NULL,state TEXT NOT NULL);")?; Ok(()) } pub fn active(db: &Connection, slot: &str) -> Result> { @@ -209,6 +210,95 @@ pub fn recover(db: &mut Connection) -> Result { Ok(ids.len()) } +/// 为已完成的升级生成回滚变更;回滚本身仍以新的 operation_id 执行和记录。 +pub fn rollback_changes(db: &Connection, operation: &str) -> Result> { + let (before, after, state): (String, String, String) = db + .query_row( + "SELECT before_state,after_state,state FROM extension_transactions WHERE id=?1", + [operation], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .map_err(|_| HostError::new("EXTENSION_ROLLBACK_UNKNOWN"))?; + if state != "complete" { + return Err(HostError::new("EXTENSION_ROLLBACK_INVALID")); + } + let old: Vec> = + serde_json::from_str(&before).map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?; + let installed: Vec = + serde_json::from_str(&after).map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?; + if old.len() != installed.len() || old.iter().any(Option::is_none) { + return Err(HostError::new("EXTENSION_ROLLBACK_INVALID")); + } + old.into_iter() + .zip(installed) + .map(|(previous, installed)| { + let previous = previous.unwrap(); + let current = active(db, &installed.target.slot)? + .ok_or_else(|| HostError::new("EXTENSION_INSTALL_CONFLICT"))?; + if current.pending_operation.is_some() || current.target != installed.target { + return Err(HostError::new("EXTENSION_INSTALL_CONFLICT")); + } + Ok(Change { + target: previous.target, + expected_revision: Some(current.revision), + }) + }) + .collect() +} + +/// 实例停止后原子移除活动指针。保留已验证对象,以便审计或显式回滚;不会触碰外部目录。 +pub fn uninstall( + db: &mut Connection, + operation: &str, + slot: &str, + expected_revision: &str, +) -> Result { + if uuid::Uuid::parse_str(operation).is_err() + || slot.len() != 64 + || expected_revision.len() != 64 + || [slot, expected_revision].iter().any(|value| { + !value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + }) + { + return Err(HostError::new("EXTENSION_TRANSACTION_INVALID")); + } + db.execute_batch("CREATE TABLE IF NOT EXISTS extension_uninstalls(id TEXT PRIMARY KEY,slot TEXT NOT NULL,expected_revision TEXT NOT NULL,state TEXT NOT NULL);")?; + let transaction = db.transaction()?; + let prior: Option<(String, String, String)> = transaction + .query_row( + "SELECT slot,expected_revision,state FROM extension_uninstalls WHERE id=?1", + [operation], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()?; + if let Some((old_slot, old_revision, state)) = prior { + if old_slot != slot || old_revision != expected_revision { + return Err(HostError::new("OPERATION_REUSED")); + } + return Ok(Receipt { + operation_id: operation.into(), + state, + }); + } + let current = + active(&transaction, slot)?.ok_or_else(|| HostError::new("EXTENSION_INSTALL_CONFLICT"))?; + if current.pending_operation.is_some() || current.revision != expected_revision { + return Err(HostError::new("EXTENSION_INSTALL_CONFLICT")); + } + transaction.execute( + "INSERT INTO extension_uninstalls VALUES (?1,?2,?3,'complete')", + params![operation, slot, expected_revision], + )?; + transaction.execute("DELETE FROM extension_active WHERE slot=?1", [slot])?; + transaction.commit()?; + Ok(Receipt { + operation_id: operation.into(), + state: "complete".into(), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -272,6 +362,50 @@ mod tests { assert!(item.pending_operation.is_none()); } } + + #[test] + fn completed_upgrade_can_rollback_then_uninstall_idempotently() { + let temp = tempfile::tempdir().unwrap(); + let mut db = open(&temp.path().join("state.sqlite3")); + let initial = change('a', 1, None); + let install = uuid::Uuid::new_v4().to_string(); + switch(&mut db, &install, std::slice::from_ref(&initial)).unwrap(); + finish(&mut db, &install, true).unwrap(); + + let installed = active(&db, &initial.target.slot).unwrap().unwrap(); + let upgrade = change('a', 2, Some(installed.revision)); + let upgrade_id = uuid::Uuid::new_v4().to_string(); + switch(&mut db, &upgrade_id, &[upgrade]).unwrap(); + finish(&mut db, &upgrade_id, true).unwrap(); + + let rollback = rollback_changes(&db, &upgrade_id).unwrap(); + let rollback_id = uuid::Uuid::new_v4().to_string(); + switch(&mut db, &rollback_id, &rollback).unwrap(); + finish(&mut db, &rollback_id, true).unwrap(); + let restored = active(&db, &initial.target.slot).unwrap().unwrap(); + assert_eq!(restored.target, initial.target); + + let uninstall_id = uuid::Uuid::new_v4().to_string(); + let receipt = uninstall( + &mut db, + &uninstall_id, + &restored.target.slot, + &restored.revision, + ) + .unwrap(); + assert_eq!(receipt.state, "complete"); + assert_eq!( + uninstall( + &mut db, + &uninstall_id, + &restored.target.slot, + &restored.revision, + ) + .unwrap(), + receipt + ); + assert!(active(&db, &restored.target.slot).unwrap().is_none()); + } #[test] fn group_switch_crashes_recover_matching_packages_and_configuration() { for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] { diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index b01652c..ba0296b 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -31,6 +31,8 @@ struct Host { extension_reviews: extension_commands::Reviews, extension_requests: Requests, extension_authority: notesagent_host::extension_permit::Authority, + #[cfg(windows)] + extension_instances: Mutex, credential_signal: std::sync::OnceLock>, sync: Arc, workspace: Arc>>, @@ -45,12 +47,20 @@ struct Host { impl Host { fn replace_workspace(&self, active: &mut Option, next: Option) { self.extension_authority.revoke(); + #[cfg(windows)] + if let Ok(mut instances) = self.extension_instances.lock() { + instances.stop_all_and_join(); + } self.sync.cancel(); *active = next; } fn lock_credentials(&self) -> Result<(), String> { // 这些不等待进行中解锁/KDF 或凭证操作。 self.extension_authority.revoke(); + #[cfg(windows)] + if let Ok(mut instances) = self.extension_instances.lock() { + instances.stop_all_and_join(); + } if let Some(signal) = self.credential_signal.get() { signal.fetch_add(1, std::sync::atomic::Ordering::SeqCst); } @@ -1064,6 +1074,9 @@ fn main() { extension_trust_confirm, extension_trust_confirm_group, extension_install_preview, + extension_install_confirm, + extension_install_rollback, + extension_uninstall, extension_stage, extension_stage_prepare, extension_stage_cancel,