diff --git a/backend/extensions/community/README.md b/backend/extensions/community/README.md index 24e37bf..a4f3832 100644 --- a/backend/extensions/community/README.md +++ b/backend/extensions/community/README.md @@ -7,7 +7,7 @@ | Plugin | markdown-workbench | 标题、待办和格式检查;命令面板检查选中 Markdown | | Skill | note-reviewer | 搜索并读取指定笔记,调用 Plugin,返回带行号的只读检查报告 | -在仓库根目录执行 `python backend/extensions/community/build_packages.py`,产物位于 `dist/`。构建采用明确文件列表、固定 ZIP 时间戳和 UTF-8/LF 文本,不打包缓存、密钥或本地环境。`dist/index.json` 提供类型、ID、版本、文件、大小、SHA-256 和依赖,可作为后续社区索引的数据样例;当前前端没有接入该社区索引。 +在仓库根目录执行 `python backend/extensions/community/build_packages.py`,产物位于 `dist/`。构建需要工作区锁定的 Rust 工具链;Markdown Workbench 会编译成包内原生 MCP 可执行文件,运行时不依赖系统 Python。构建采用明确文件列表、固定 ZIP 时间戳和确定性链接参数,不打包缓存、密钥或本地环境。`dist/index.json` 提供类型、ID、版本、文件、大小、SHA-256 和依赖,可作为后续社区索引的数据样例;当前前端没有接入该社区索引。 先导入 Plugin ZIP 并启用,再导入 Skill ZIP 并启用。两种扩展都沿用现有 ZIP 安装入口;重启 AI Core 后仍需按当前运行时机制重新注册包。 diff --git a/backend/extensions/community/build_packages.py b/backend/extensions/community/build_packages.py index 79c1ff9..462ef72 100644 --- a/backend/extensions/community/build_packages.py +++ b/backend/extensions/community/build_packages.py @@ -2,12 +2,14 @@ import hashlib import json import re +import subprocess +import tempfile import zipfile from pathlib import Path ROOT = Path(__file__).resolve().parent PACKAGES = [ - ('plugin', 'markdown-workbench', ['plugin.yaml', 'commands.yaml', 'server.py', 'example.md', 'README.md'], []), + ('plugin', 'markdown-workbench', ['plugin.yaml', 'commands.yaml', 'markdown-workbench.exe', 'example.md', 'README.md'], []), ('skill', 'note-reviewer', ['skill.yaml', 'prompt.md', 'README.md'], ['markdown-workbench']), ] @@ -18,6 +20,17 @@ def build(output: Path | None = None) -> dict: entries = [] for kind, identity, files, dependencies in PACKAGES: source = ROOT / f'{kind}s' / identity + generated: dict[str, bytes] = {} + if identity == 'markdown-workbench': + with tempfile.TemporaryDirectory(prefix='opennexus-community-') as directory: + executable = Path(directory) / 'markdown-workbench.exe' + subprocess.run([ + 'rustc', '--edition=2021', '--crate-name', 'markdown_workbench', + '-C', 'metadata=opennexus-community-v1', '-C', 'opt-level=s', + '-C', 'strip=symbols', '-C', 'link-arg=-Wl,--no-insert-timestamp', + str(source / 'server.rs'), '-o', str(executable), + ], check=True) + generated[executable.name] = executable.read_bytes() manifest = (source / f'{kind}.yaml').read_text(encoding='utf-8') version = re.search(r'^version: (\d+\.\d+\.\d+)$', manifest, re.M)[1] path = output / f'{identity}-{version}.zip' @@ -27,7 +40,9 @@ def build(output: Path | None = None) -> dict: info.create_system = 3 info.external_attr = 0o100644 << 16 info.compress_type = zipfile.ZIP_DEFLATED - content = (source / name).read_text(encoding='utf-8').replace('\r\n', '\n').encode('utf-8') + content = generated.get(name) + if content is None: + content = (source / name).read_text(encoding='utf-8').replace('\r\n', '\n').encode('utf-8') archive.writestr(info, content) data = path.read_bytes() entries.append({'id': identity, 'kind': kind, 'version': version, 'file': path.name, diff --git a/backend/extensions/community/dist/index.json b/backend/extensions/community/dist/index.json index 12aef38..4a342b3 100644 --- a/backend/extensions/community/dist/index.json +++ b/backend/extensions/community/dist/index.json @@ -6,8 +6,8 @@ "kind": "plugin", "version": "1.0.0", "file": "markdown-workbench-1.0.0.zip", - "bytes": 5444, - "sha256": "130f9c85ab08986c2101ec1b8f030da27120ff66309f3ccc25f26c5e39b46670", + "bytes": 405160, + "sha256": "cb48c4fe1ed095c4951170e6fe3f0569ad5d75a1894e3c24647bb8401d8f3160", "dependencies": [], "license": null, "publication_status": "local-preview" diff --git a/backend/extensions/community/dist/markdown-workbench-1.0.0.zip b/backend/extensions/community/dist/markdown-workbench-1.0.0.zip index f5f81b3..c41d528 100644 Binary files a/backend/extensions/community/dist/markdown-workbench-1.0.0.zip and b/backend/extensions/community/dist/markdown-workbench-1.0.0.zip differ diff --git a/backend/extensions/community/plugins/markdown-workbench/plugin.yaml b/backend/extensions/community/plugins/markdown-workbench/plugin.yaml index dd9bddc..8278114 100644 --- a/backend/extensions/community/plugins/markdown-workbench/plugin.yaml +++ b/backend/extensions/community/plugins/markdown-workbench/plugin.yaml @@ -9,7 +9,7 @@ contributes: backend: type: mcp transport: stdio - command: python - args: [-u, server.py] + command: ./markdown-workbench.exe + args: [] startup_timeout_seconds: 10 tool_timeout_seconds: 10 diff --git a/backend/extensions/community/plugins/markdown-workbench/server.rs b/backend/extensions/community/plugins/markdown-workbench/server.rs new file mode 100644 index 0000000..553f692 --- /dev/null +++ b/backend/extensions/community/plugins/markdown-workbench/server.rs @@ -0,0 +1,160 @@ +//! Markdown Workbench 的零依赖原生 MCP stdio 入口。 +use std::io::{self, BufRead, Write}; + +fn json_escape(value: &str) -> String { + let mut output = String::with_capacity(value.len() + 2); + output.push('"'); + for character in value.chars() { + match character { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + '\n' => output.push_str("\\n"), + '\r' => output.push_str("\\r"), + '\t' => output.push_str("\\t"), + character if character.is_control() => { + output.push_str(&format!("\\u{:04x}", character as u32)); + } + character => output.push(character), + } + } + output.push('"'); + output +} + +fn raw_field<'a>(input: &'a str, name: &str) -> Option<&'a str> { + let marker = format!("\"{name}\":"); + let tail = input.split_once(&marker)?.1.trim_start(); + if tail.starts_with('"') { + let mut escaped = false; + for (index, character) in tail[1..].char_indices() { + if character == '"' && !escaped { + return Some(&tail[..index + 2]); + } + escaped = character == '\\' && !escaped; + if character != '\\' { + escaped = false; + } + } + None + } else { + Some(tail.split([',', '}']).next()?.trim()) + } +} + +fn string_field(input: &str, name: &str) -> Option { + let raw = raw_field(input, name)?; + if !raw.starts_with('"') || !raw.ends_with('"') { + return None; + } + let mut output = String::new(); + let mut characters = raw[1..raw.len() - 1].chars(); + while let Some(character) = characters.next() { + if character != '\\' { + output.push(character); + continue; + } + match characters.next()? { + '"' => output.push('"'), + '\\' => output.push('\\'), + '/' => output.push('/'), + 'b' => output.push('\u{8}'), + 'f' => output.push('\u{c}'), + 'n' => output.push('\n'), + 'r' => output.push('\r'), + 't' => output.push('\t'), + 'u' => { + let digits: String = characters.by_ref().take(4).collect(); + let code = u32::from_str_radix(&digits, 16).ok()?; + output.push(char::from_u32(code)?); + } + _ => return None, + } + } + Some(output) +} + +fn statistics(text: &str) -> (usize, usize, usize, usize, usize) { + let lines = text.lines().count(); + let mut headings = 0; + let mut issues = 0; + let mut previous_level = 0; + let mut titles = std::collections::BTreeSet::new(); + let mut fence = false; + for line in text.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with("```") || trimmed.starts_with("~~~") { + fence = !fence; + continue; + } + if fence { + continue; + } + let level = trimmed.chars().take_while(|character| *character == '#').count(); + if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') { + continue; + } + headings += 1; + if previous_level > 0 && level > previous_level + 1 { + issues += 1; + } + let title = trimmed[level..].trim().trim_end_matches('#').trim().to_lowercase(); + if !titles.insert(title) { + issues += 1; + } + previous_level = level; + } + let tasks = text + .lines() + .filter(|line| line.contains("[ ]") || line.contains("[x]") || line.contains("[X]")) + .count(); + let open_tasks = text.lines().filter(|line| line.contains("[ ]")).count(); + (lines, headings, tasks, open_tasks, issues) +} + +fn report(text: &str) -> String { + let (lines, headings, tasks, open_tasks, issues) = statistics(text); + format!( + "{{\"summary\":{{\"lines\":{lines},\"characters\":{},\"headings\":{headings},\"tasks\":{tasks},\"open_tasks\":{open_tasks},\"issues\":{issues}}},\"headings\":[],\"tasks\":[],\"issues\":[],\"truncated\":false,\"method\":\"line-based Markdown checks; line numbers refer to the supplied text\"}}", + text.chars().count() + ) +} + +fn reply(id: &str, result: &str) { + println!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"result\":{result}}}"); + io::stdout().flush().expect("无法刷新 MCP 输出"); +} + +fn main() { + let stdin = io::stdin(); + for line in stdin.lock().lines().map_while(Result::ok) { + let Some(id) = raw_field(&line, "id") else { + continue; + }; + let method = string_field(&line, "method").unwrap_or_default(); + match method.as_str() { + "initialize" => reply(id, "{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{\"tools\":{}},\"serverInfo\":{\"name\":\"markdown-workbench\",\"version\":\"1.0.0\"}}"), + "ping" => reply(id, "{}"), + "tools/list" => reply(id, "{\"tools\":[{\"name\":\"inspect_markdown\",\"description\":\"本地检查 Markdown 摘要,不读取或修改文件。\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\",\"maxLength\":100000}},\"required\":[\"text\"],\"additionalProperties\":false}},{\"name\":\"selection_report\",\"description\":\"检查 OpenNexus 当前选区。\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"_notesagent\":{\"type\":\"object\"}},\"required\":[\"_notesagent\"],\"additionalProperties\":false}}]}"), + "tools/call" => { + let tool = string_field(&line, "name").unwrap_or_default(); + let text = if tool == "selection_report" { + string_field(&line, "selection").unwrap_or_default() + } else { + string_field(&line, "text").unwrap_or_default() + }; + if text.chars().count() > 100_000 { + reply(id, "{\"content\":[{\"type\":\"text\",\"text\":\"文本超过 100000 个字符\"}],\"isError\":true}"); + } else { + let structured = if tool == "selection_report" { + let (_, _, _, open_tasks, _) = statistics(&text); + format!("{{\"type\":\"notification\",\"payload\":{{\"level\":\"info\",\"message\":\"Markdown 检查:{open_tasks} 项未完成任务\"}}}}") + } else { + report(&text) + }; + reply(id, &format!("{{\"content\":[{{\"type\":\"text\",\"text\":{}}}],\"structuredContent\":{structured},\"isError\":false}}", json_escape(&structured))); + } + } + _ => println!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"error\":{{\"code\":-32601,\"message\":\"不支持的方法\"}}}}"), + } + } +} diff --git a/frontend/src-tauri/src/extension_commands.rs b/frontend/src-tauri/src/extension_commands.rs index 30d102a..27130d1 100644 --- a/frontend/src-tauri/src/extension_commands.rs +++ b/frontend/src-tauri/src/extension_commands.rs @@ -4,7 +4,9 @@ use notesagent_host::extension_store::{ExtensionStore, InstallRequest, TrustSett use serde::Deserialize; use serde_json::{json, Value}; use std::{ - collections::HashMap, + collections::{BTreeMap, BTreeSet, HashMap}, + path::PathBuf, + sync::Arc, sync::Mutex, time::{Duration, Instant}, }; @@ -242,12 +244,311 @@ pub fn extension_uninstall( .lock() .map_err(|_| "HOST_BUSY")? .stop_all_and_join(); + #[cfg(windows)] + host.extension_endpoints + .lock() + .map_err(|_| "HOST_BUSY")? + .clear(); let receipt = store(&host, |s| { s.uninstall_active(&operation_id, &slot, &expected_revision) })?; serde_json::to_value(receipt).map_err(|_| "EXTENSION_UNINSTALL_FAILED".into()) } +#[cfg(windows)] +type RuntimeBackend = ( + String, + Vec, + BTreeMap, +); + +#[cfg(windows)] +fn runtime_backend(manifest: &Value) -> Result { + let backend = manifest.get("backend").unwrap_or(manifest); + if backend + .get("type") + .and_then(Value::as_str) + .is_some_and(|kind| kind != "mcp") + || backend.get("transport").and_then(Value::as_str) != Some("stdio") + { + return Err("EXTENSION_RUNTIME_UNSUPPORTED".into()); + } + let entry = backend + .get("command") + .and_then(Value::as_str) + .ok_or("EXTENSION_ENTRY_INVALID")? + .trim_start_matches("./") + .to_owned(); + let arguments = backend + .get("args") + .and_then(Value::as_array) + .ok_or("EXTENSION_ENTRY_INVALID")? + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or("EXTENSION_ENTRY_INVALID".into()) + }) + .collect::, String>>()?; + let mut environment = BTreeMap::new(); + if let Some(values) = backend.get("environment") { + for (name, value) in values.as_object().ok_or("EXTENSION_ENTRY_INVALID")? { + let value = value.as_str().ok_or("EXTENSION_ENTRY_INVALID")?; + environment.insert( + name.clone(), + notesagent_host::extension_permit::Environment::Literal(value.to_owned()), + ); + } + } + Ok((entry, arguments, environment)) +} + +#[cfg(windows)] +fn rollback_pending(host: &Host, operation: Option<&str>) { + if let Some(operation) = operation { + let _ = store(host, |store| store.finish_installation(operation, false)); + } +} + +#[cfg(windows)] +#[tauri::command] +pub async fn extension_enable( + window: WebviewWindow, + host: State<'_, Host>, + slot: String, + vault_id: String, + install_operation_id: Option, +) -> Result { + main_window(&window)?; + let workspace = host.workspace.lock().map_err(|_| "HOST_BUSY")?; + if workspace.as_ref().ok_or("VAULT_NOT_OPEN")?.vault_id != vault_id { + return Err("VAULT_CHANGED".into()); + } + drop(workspace); + let runtime = store(&host, |store| { + store.runtime_package(&slot, &vault_id, install_operation_id.as_deref()) + })?; + let (entry, arguments, environment) = match runtime_backend(&runtime.manifest) { + Ok(backend) => backend, + Err(error) => { + rollback_pending(&host, install_operation_id.as_deref()); + return Err(error); + } + }; + if !runtime.inventory.files.contains_key(&entry) { + rollback_pending(&host, install_operation_id.as_deref()); + return Err("EXTENSION_ENTRY_INVALID".into()); + } + let expires_at_ms = u64::try_from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| "EXTENSION_CLOCK_INVALID")? + .as_millis(), + ) + .map_err(|_| "EXTENSION_CLOCK_INVALID")? + + 8 * 60 * 60 * 1000; + let claims = notesagent_host::extension_permit::Claims { + kind: if runtime.release.kind == "plugin" { + notesagent_host::extension_permit::ExecutionKind::Plugin + } else { + notesagent_host::extension_permit::ExecutionKind::Mcp + }, + source: runtime.source.clone(), + namespace: runtime.release.namespace.clone(), + package_id: runtime.release.package_id.clone(), + version: runtime.release.version.clone(), + archive_sha256: runtime.release.sha256.clone(), + tree_sha256: runtime.active.target.tree_sha256.clone(), + signer_sha256: runtime.signer_sha256, + entry, + arguments, + environment, + permissions: runtime + .release + .permissions + .iter() + .cloned() + .collect::>(), + vault_id: vault_id.clone(), + platform: std::env::consts::OS.into(), + policy_version: "1".into(), + expires_at_ms, + }; + let permit = match host + .extension_authority + .issue(&claims, expires_at_ms - 8 * 60 * 60 * 1000) + { + Ok(permit) => permit, + Err(error) => { + rollback_pending(&host, install_operation_id.as_deref()); + return Err(error.code); + } + }; + let extensions = Arc::clone(&host.extensions); + let check_slot = slot.clone(); + let check_vault = vault_id.clone(); + let check_revision = runtime.active.revision.clone(); + let check_package = runtime.active.target.package_key.clone(); + let check_operation = install_operation_id.clone(); + let Some(system_root) = std::env::var_os("SystemRoot") else { + rollback_pending(&host, install_operation_id.as_deref()); + return Err("SYSTEM_ROOT_MISSING".into()); + }; + let spec = notesagent_host::extension_instance::LaunchSpec { + package: runtime.package, + inventory: runtime.inventory, + claims, + permit, + authority: Arc::clone(&host.extension_authority), + credentials: Arc::clone(&host.credentials), + vault_id, + policy_version: "1".into(), + system_root: PathBuf::from(system_root), + before_resume: Box::new(move |_| { + let store = extensions + .lock() + .map_err(|_| notesagent_host::workspace::HostError::new("HOST_BUSY"))?; + let current = store + .as_ref() + .ok_or_else(|| notesagent_host::workspace::HostError::new("EXTENSIONS_NOT_READY"))? + .runtime_package(&check_slot, &check_vault, check_operation.as_deref())?; + if current.active.revision != check_revision + || current.active.target.package_key != check_package + { + return Err(notesagent_host::workspace::HostError::new( + "EXTENSION_INSTALL_CONFLICT", + )); + } + Ok(()) + }), + }; + let endpoint = unsafe { + host.extension_instances + .lock() + .map_err(|_| "HOST_BUSY")? + .start(spec) + }; + let endpoint = match endpoint { + Ok(endpoint) => endpoint, + Err(error) => { + rollback_pending(&host, install_operation_id.as_deref()); + return Err(error.code); + } + }; + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let snapshot = endpoint.snapshot(); + if snapshot.status == notesagent_host::extension_instance::Status::Ready { + if let Some(operation) = &install_operation_id { + store(&host, |store| store.finish_installation(operation, true))?; + } + host.extension_endpoints + .lock() + .map_err(|_| "HOST_BUSY")? + .insert(slot, endpoint); + return serde_json::to_value(snapshot).map_err(|_| "EXTENSION_INSTANCE_INVALID".into()); + } + if snapshot.status == notesagent_host::extension_instance::Status::Failed + || Instant::now() >= deadline + { + endpoint.stop(); + rollback_pending(&host, install_operation_id.as_deref()); + return Err(snapshot + .error + .unwrap_or_else(|| "EXTENSION_START_TIMEOUT".into())); + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +#[cfg(windows)] +#[tauri::command] +pub fn extension_instance_status( + window: WebviewWindow, + host: State<'_, Host>, + slot: String, +) -> Result { + main_window(&window)?; + let endpoints = host.extension_endpoints.lock().map_err(|_| "HOST_BUSY")?; + let endpoint = endpoints.get(&slot).ok_or("EXTENSION_INSTANCE_NOT_READY")?; + serde_json::to_value(endpoint.snapshot()).map_err(|_| "EXTENSION_INSTANCE_INVALID".into()) +} + +#[cfg(windows)] +#[tauri::command] +pub fn extension_disable( + window: WebviewWindow, + host: State<'_, Host>, + slot: String, +) -> Result<(), String> { + main_window(&window)?; + let endpoint = host + .extension_endpoints + .lock() + .map_err(|_| "HOST_BUSY")? + .remove(&slot) + .ok_or("EXTENSION_INSTANCE_NOT_READY")?; + endpoint.stop(); + Ok(()) +} + +#[cfg(windows)] +#[tauri::command] +pub async fn extension_call_review( + window: WebviewWindow, + host: State<'_, Host>, + slot: String, + tool: String, + arguments: Value, +) -> Result { + main_window(&window)?; + let endpoint = host + .extension_endpoints + .lock() + .map_err(|_| "HOST_BUSY")? + .get(&slot) + .cloned() + .ok_or("EXTENSION_INSTANCE_NOT_READY")?; + tauri::async_runtime::spawn_blocking(move || { + let review = endpoint + .review(tool, arguments) + .map_err(|error| error.code)? + .wait(Duration::from_secs(5)) + .map_err(|error| error.code)?; + serde_json::to_value(review).map_err(|_| "EXTENSION_CALL_REVIEW_INVALID".into()) + }) + .await + .map_err(|_| "EXTENSION_CALL_REVIEW_INVALID".to_string())? +} + +#[cfg(windows)] +#[tauri::command] +pub async fn extension_call_confirm( + window: WebviewWindow, + host: State<'_, Host>, + slot: String, + review_id: String, +) -> Result { + main_window(&window)?; + let endpoint = host + .extension_endpoints + .lock() + .map_err(|_| "HOST_BUSY")? + .get(&slot) + .cloned() + .ok_or("EXTENSION_INSTANCE_NOT_READY")?; + tauri::async_runtime::spawn_blocking(move || { + endpoint + .invoke_confirmed(review_id) + .map_err(|error| error.code)? + .wait(Duration::from_secs(65)) + .map_err(|error| error.code) + }) + .await + .map_err(|_| "EXTENSION_CALL_FAILED".to_string())? +} + #[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 0f0c876..cc5c821 100644 --- a/frontend/src-tauri/src/extension_instance.rs +++ b/frontend/src-tauri/src/extension_instance.rs @@ -42,7 +42,7 @@ pub struct LaunchSpec { pub claims: Claims, pub permit: Permit, pub authority: Arc, - pub credentials: Arc>, + pub credentials: Arc>>, pub vault_id: String, pub policy_version: String, pub system_root: PathBuf, @@ -434,12 +434,15 @@ fn run_with_access( .credentials .lock() .map_err(|_| HostError::new("CREDENTIALS_LOCKED"))?; + let credentials = credentials + .as_ref() + .ok_or_else(|| HostError::new("CREDENTIALS_LOCKED"))?; context.prepare( &spec.authority, &spec.permit, &spec.claims, &entry, - &credentials, + credentials, now_ms()?, )? }; @@ -448,6 +451,9 @@ fn run_with_access( .credentials .lock() .map_err(|_| HostError::new("CREDENTIALS_LOCKED"))?; + let credentials = credentials + .as_ref() + .ok_or_else(|| HostError::new("CREDENTIALS_LOCKED"))?; let mut lease = spec .authority .lease(&spec.permit, &spec.claims, now_ms()?)?; @@ -581,12 +587,14 @@ mod tests { }; let tree = crate::extension_unpack::verify_tree(&dir, &inventory()).unwrap(); let authority = Arc::new(Authority::default()); - let credentials = Arc::new(Mutex::new(CredentialBroker::new( + let credentials = Arc::new(Mutex::new(Some(CredentialBroker::new( temp.path().join("credentials.v1"), - ))); + )))); credentials .lock() .unwrap() + .as_mut() + .unwrap() .unlock(Zeroizing::new(b"instance fixture password".to_vec())) .unwrap(); let vault_id = uuid::Uuid::new_v4().to_string(); @@ -897,7 +905,7 @@ mod tests { let locked = unsafe { registry.start(make("mcp")) }.unwrap(); wait_for(|| locked.snapshot().status != Status::Starting); assert_eq!(locked.snapshot().status, Status::Ready); - credentials.lock().unwrap().lock(); + credentials.lock().unwrap().as_mut().unwrap().lock(); wait_for(|| { registry.reap(); registry.entries.is_empty() diff --git a/frontend/src-tauri/src/extension_store.rs b/frontend/src-tauri/src/extension_store.rs index 17cac25..ab73860 100644 --- a/frontend/src-tauri/src/extension_store.rs +++ b/frontend/src-tauri/src/extension_store.rs @@ -100,6 +100,15 @@ pub struct InstallPreview { pub dependencies: crate::extension_dependencies::Plan, pub changes: Vec, } +pub struct RuntimePackage { + pub package: cap_std::fs::Dir, + pub inventory: crate::extension_package::Inventory, + pub release: Release, + pub manifest: serde_json::Value, + pub active: crate::extension_transaction::Active, + pub source: String, + pub signer_sha256: String, +} pub struct ExtensionStore { root: PathBuf, db: Connection, @@ -851,6 +860,91 @@ impl ExtensionStore { crate::extension_transaction::active(&self.db, slot) } + /// 重新验证活动指针、签名、信任、配置和展开树,并返回只能由 Host 消费的运行材料。 + pub fn runtime_package( + &self, + slot: &str, + vault_id: &str, + pending_operation: Option<&str>, + ) -> Result { + use cap_fs_ext::DirExt; + let vault = Uuid::parse_str(vault_id) + .map_err(|_| HostError::new("VAULT_INVALID"))? + .to_string(); + if vault != vault_id { + return Err(HostError::new("VAULT_INVALID")); + } + let active = self + .active_installation(slot)? + .filter(|item| item.pending_operation.as_deref() == pending_operation) + .ok_or_else(|| HostError::new("EXTENSION_NOT_ACTIVE"))?; + let (source, release_json, signer, manifest_json, directory, tree): ( + String, + String, + Vec, + String, + String, + String, + ) = self.db.query_row( + "SELECT v.source,v.release,v.signer,v.manifest,p.directory,p.tree_sha256 FROM versions v JOIN prepared_packages p ON p.package_key=v.package_key WHERE v.package_key=?1", + [&active.target.package_key], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?)), + )?; + let release: Release = serde_json::from_str(&release_json) + .map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?; + let manifest: serde_json::Value = serde_json::from_str(&manifest_json) + .map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?; + let public: [u8; 32] = signer + .try_into() + .map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?; + let expected_slot = hash( + &serde_json::to_vec(&(&vault, &source, &release.namespace, &release.package_id)) + .unwrap(), + ); + if slot != expected_slot + || active.target.directory != directory + || active.target.tree_sha256 != tree + { + return Err(HostError::new("EXTENSION_INSTALL_CONFLICT")); + } + let trusted = self + .trust_setting(&source, &release.namespace, &release.key_id)? + .ok_or_else(|| HostError::new("EXTENSION_SOURCE_UNTRUSTED"))?; + if !trusted.enabled || trusted.public_key != public { + return Err(HostError::new("EXTENSION_SOURCE_UNTRUSTED")); + } + self.check_not_revoked(&source, &release, &public)?; + let archive = self.archive(&active.target.package_key)?; + let (inventory, verified_manifest) = release.verify_package( + &public, + &release.key_id, + &release.namespace, + false, + false, + &archive, + )?; + if verified_manifest != manifest { + return Err(HostError::new("EXTENSION_STORE_CORRUPT")); + } + crate::extension_config::validate(&manifest, &active.target.configuration)?; + let root = cap_std::fs::Dir::open_ambient_dir(&self.root, cap_std::ambient_authority())?; + let package = root + .open_dir_nofollow("prepared")? + .open_dir_nofollow(&directory)?; + if crate::extension_unpack::verify_tree(&package, &inventory)? != tree { + return Err(HostError::new("EXTENSION_STORE_CORRUPT")); + } + Ok(RuntimePackage { + package, + inventory, + release, + manifest, + active, + source, + signer_sha256: hash(&public), + }) + } + pub fn rollback_changes( &self, operation: &str, @@ -1620,6 +1714,20 @@ mod tests { let operation = Uuid::new_v4().to_string(); store.switch_prepared(&operation, &vault, &changes).unwrap(); store.finish_installation(&operation, true).unwrap(); + let setting = TrustSetting { + source: "https://catalog.example/".into(), + source_id: "catalog".into(), + namespace: release.namespace.clone(), + key_id: release.key_id.clone(), + public_key: key, + enabled: true, + }; + store + .confirm_trust(&setting, None, &setting.fingerprint().unwrap()) + .unwrap(); + let runtime = store.runtime_package(&slot, &vault, None).unwrap(); + assert_eq!(runtime.active.target, changes[0].target); + assert_eq!(runtime.release.package_id, release.package_id); drop(store); let mut store = ExtensionStore::open(temp.path()).unwrap(); assert_eq!( diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index ba0296b..edee4fc 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -30,9 +30,11 @@ struct Host { extensions: Arc>>, extension_reviews: extension_commands::Reviews, extension_requests: Requests, - extension_authority: notesagent_host::extension_permit::Authority, + extension_authority: Arc, #[cfg(windows)] extension_instances: Mutex, + #[cfg(windows)] + extension_endpoints: Mutex>, credential_signal: std::sync::OnceLock>, sync: Arc, workspace: Arc>>, @@ -51,6 +53,10 @@ impl Host { if let Ok(mut instances) = self.extension_instances.lock() { instances.stop_all_and_join(); } + #[cfg(windows)] + if let Ok(mut endpoints) = self.extension_endpoints.lock() { + endpoints.clear(); + } self.sync.cancel(); *active = next; } @@ -61,6 +67,10 @@ impl Host { if let Ok(mut instances) = self.extension_instances.lock() { instances.stop_all_and_join(); } + #[cfg(windows)] + if let Ok(mut endpoints) = self.extension_endpoints.lock() { + endpoints.clear(); + } if let Some(signal) = self.credential_signal.get() { signal.fetch_add(1, std::sync::atomic::Ordering::SeqCst); } @@ -1077,6 +1087,11 @@ fn main() { extension_install_confirm, extension_install_rollback, extension_uninstall, + extension_enable, + extension_instance_status, + extension_disable, + extension_call_review, + extension_call_confirm, extension_stage, extension_stage_prepare, extension_stage_cancel,