feat: 接通原生社区扩展运行链路

This commit is contained in:
2026-09-12 02:15:23 +08:00
parent 46cbe7d711
commit 226a8ce4d8
10 changed files with 621 additions and 14 deletions
+302 -1
View File
@@ -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<String>,
BTreeMap<String, notesagent_host::extension_permit::Environment>,
);
#[cfg(windows)]
fn runtime_backend(manifest: &Value) -> Result<RuntimeBackend, String> {
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::<Result<Vec<_>, 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<String>,
) -> Result<Value, String> {
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::<BTreeSet<_>>(),
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<Value, String> {
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<Value, String> {
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<Value, String> {
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 {
+13 -5
View File
@@ -42,7 +42,7 @@ pub struct LaunchSpec {
pub claims: Claims,
pub permit: Permit,
pub authority: Arc<Authority>,
pub credentials: Arc<Mutex<CredentialBroker>>,
pub credentials: Arc<Mutex<Option<CredentialBroker>>>,
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()
+108
View File
@@ -100,6 +100,15 @@ pub struct InstallPreview {
pub dependencies: crate::extension_dependencies::Plan,
pub changes: Vec<crate::extension_transaction::Change>,
}
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<RuntimePackage> {
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<u8>,
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!(
+16 -1
View File
@@ -30,9 +30,11 @@ struct Host {
extensions: Arc<Mutex<Option<notesagent_host::extension_store::ExtensionStore>>>,
extension_reviews: extension_commands::Reviews,
extension_requests: Requests,
extension_authority: notesagent_host::extension_permit::Authority,
extension_authority: Arc<notesagent_host::extension_permit::Authority>,
#[cfg(windows)]
extension_instances: Mutex<notesagent_host::extension_instance::Registry>,
#[cfg(windows)]
extension_endpoints: Mutex<HashMap<String, notesagent_host::extension_instance::Endpoint>>,
credential_signal: std::sync::OnceLock<Arc<std::sync::atomic::AtomicU64>>,
sync: Arc<sync_commands::Runtime>,
workspace: Arc<Mutex<Option<Workspace>>>,
@@ -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,